server.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150
  1. import asyncio
  2. import json
  3. from pathlib import Path
  4. from typing import Dict, List, Optional
  5. from datetime import datetime
  6. from collections import deque
  7. from fastapi import FastAPI, HTTPException, Request, BackgroundTasks, UploadFile, File
  8. from fastapi.staticfiles import StaticFiles
  9. from fastapi.responses import HTMLResponse
  10. from pydantic import BaseModel
  11. import uvicorn
  12. import sys
  13. app = FastAPI(title="Pipeline Dashboard")
  14. BASE_DIR = Path(__file__).parent
  15. OUTPUT_DIR = BASE_DIR / "output"
  16. DB_PATH = BASE_DIR / "db_requirements.json"
  17. PROMPTS_DIR = BASE_DIR / "prompts"
  18. SCRATCH_DIR = BASE_DIR / "scratch"
  19. SCRATCH_DIR.mkdir(exist_ok=True)
  20. # In-memory storage for active runs
  21. class ActiveRun:
  22. def __init__(self):
  23. self.process: Optional[asyncio.subprocess.Process] = None
  24. self.logs: deque = deque(maxlen=200) # Keep last 200 lines
  25. self.status: str = "starting"
  26. self.start_time: str = datetime.now().isoformat()
  27. active_runs: Dict[int, ActiveRun] = {}
  28. @app.post("/api/requirements/{index}/upload_source_ex")
  29. async def upload_source_ex(index: int, file: UploadFile = File(...)):
  30. idx_str = f"{(index+1):03d}"
  31. dir_path = OUTPUT_DIR / idx_str / "raw_cases"
  32. dir_path.mkdir(parents=True, exist_ok=True)
  33. file_path = dir_path / "source_ex.json"
  34. content = await file.read()
  35. with open(file_path, "wb") as f:
  36. f.write(content)
  37. return {"status": "success", "filename": "source_ex.json"}
  38. class RunRequest(BaseModel):
  39. platforms: Optional[str] = None
  40. use_claude_sdk: bool = False
  41. restart_mode: Optional[str] = None
  42. phase: Optional[int] = None
  43. only_step: Optional[str] = None
  44. start_from: Optional[str] = None
  45. end_at: Optional[str] = None
  46. case_index: Optional[int] = None
  47. model: Optional[str] = None
  48. class MemoRequest(BaseModel):
  49. memo: str
  50. class RequirementUpdateRequest(BaseModel):
  51. requirement: str
  52. class PromptRequest(BaseModel):
  53. content: str
  54. schema_content: Optional[str] = None
  55. class ImportExternalRequest(BaseModel):
  56. case_id: str
  57. class CaseFilterRequest(BaseModel):
  58. case_id: str
  59. reason: Optional[str] = "manual_delete"
  60. from typing import Dict, List, Optional, Any
  61. class RunScriptRequest(BaseModel):
  62. folder: str
  63. filename: str
  64. args: List[Dict[str, Any]]
  65. @app.post("/api/requirements/{index}/import_source_ex")
  66. def import_source_ex(index: int, req: ImportExternalRequest):
  67. idx_str = f"{(index+1):03d}"
  68. dir_path = OUTPUT_DIR / idx_str / "raw_cases"
  69. source_ex_path = dir_path / "source_ex.json"
  70. source_path = dir_path / "source.json"
  71. if not source_ex_path.exists():
  72. raise HTTPException(status_code=404, detail="source_ex.json not found")
  73. with open(source_ex_path, "r", encoding="utf-8") as f:
  74. ex_data = json.load(f)
  75. ex_cases = ex_data if isinstance(ex_data, list) else (ex_data.get("cases") or ex_data.get("sources") or [])
  76. target_case = None
  77. for c in ex_cases:
  78. cId = c.get("case_id") or (c.get("_raw") and c["_raw"].get("case_id")) or (c.get("post") and c["post"].get("channel_content_id"))
  79. if cId == req.case_id:
  80. target_case = c
  81. break
  82. if not target_case:
  83. raise HTTPException(status_code=404, detail="Case not found in external sources")
  84. source_cases = []
  85. if source_path.exists():
  86. with open(source_path, "r", encoding="utf-8") as f:
  87. s_data = json.load(f)
  88. source_cases = s_data if isinstance(s_data, list) else (s_data.get("cases") or s_data.get("sources") or [])
  89. # Check if already exists
  90. for c in source_cases:
  91. cId = c.get("case_id") or (c.get("_raw") and c["_raw"].get("case_id")) or (c.get("post") and c["post"].get("channel_content_id"))
  92. if cId == req.case_id:
  93. return {"status": "success", "message": "Already imported"}
  94. # Append
  95. source_cases.append(target_case)
  96. with open(source_path, "w", encoding="utf-8") as f:
  97. # Save as object format for source.json
  98. json.dump({"total": len(source_cases), "sources": source_cases}, f, ensure_ascii=False, indent=2)
  99. return {"status": "success"}
  100. @app.post("/api/requirements/{index}/import_all_source_ex")
  101. def import_all_source_ex(index: int):
  102. idx_str = f"{(index+1):03d}"
  103. dir_path = OUTPUT_DIR / idx_str / "raw_cases"
  104. source_ex_path = dir_path / "source_ex.json"
  105. source_path = dir_path / "source.json"
  106. if not source_ex_path.exists():
  107. raise HTTPException(status_code=404, detail="source_ex.json not found")
  108. with open(source_ex_path, "r", encoding="utf-8") as f:
  109. ex_data = json.load(f)
  110. ex_cases = ex_data if isinstance(ex_data, list) else (ex_data.get("cases") or ex_data.get("sources") or [])
  111. source_cases = []
  112. if source_path.exists():
  113. with open(source_path, "r", encoding="utf-8") as f:
  114. s_data = json.load(f)
  115. source_cases = s_data if isinstance(s_data, list) else (s_data.get("cases") or s_data.get("sources") or [])
  116. existing_ids = set()
  117. for c in source_cases:
  118. cId = c.get("case_id") or (c.get("_raw") and c["_raw"].get("case_id")) or (c.get("post") and c["post"].get("channel_content_id"))
  119. if cId:
  120. existing_ids.add(cId)
  121. added_count = 0
  122. for c in ex_cases:
  123. cId = c.get("case_id") or (c.get("_raw") and c["_raw"].get("case_id")) or (c.get("post") and c["post"].get("channel_content_id"))
  124. if cId and cId not in existing_ids:
  125. source_cases.append(c)
  126. existing_ids.add(cId)
  127. added_count += 1
  128. if added_count > 0:
  129. with open(source_path, "w", encoding="utf-8") as f:
  130. json.dump({"total": len(source_cases), "sources": source_cases}, f, ensure_ascii=False, indent=2)
  131. return {"status": "success", "count": added_count}
  132. @app.get("/api/requirements")
  133. def get_requirements():
  134. if not DB_PATH.exists():
  135. raise HTTPException(status_code=404, detail="db_requirements.json not found")
  136. with open(DB_PATH, "r", encoding="utf-8") as f:
  137. reqs = json.load(f)
  138. results = []
  139. for i, req in enumerate(reqs):
  140. idx_str = f"{(i+1):03d}"
  141. dir_path = OUTPUT_DIR / idx_str
  142. has_strategy = (dir_path / "strategy.json").exists()
  143. has_blueprint = (dir_path / "blueprint.json").exists()
  144. has_caps = (dir_path / "capabilities_extracted.json").exists()
  145. raw_cases_count = 0
  146. case_json_path = dir_path / "case.json"
  147. if case_json_path.exists():
  148. try:
  149. with open(case_json_path, 'r', encoding='utf-8') as f:
  150. case_data = json.load(f)
  151. raw_cases_count = len(case_data.get('cases', []))
  152. except Exception:
  153. pass
  154. if raw_cases_count == 0:
  155. raw_cases_dir = dir_path / "raw_cases"
  156. if raw_cases_dir.exists():
  157. raw_cases_count = len(list(raw_cases_dir.glob("case_*.json")))
  158. status = "completed" if has_strategy else ("partial" if raw_cases_count > 0 else "pending")
  159. if i in active_runs and active_runs[i].status == "running":
  160. status = "running"
  161. memo_content = ""
  162. memo_path = dir_path / "memo.txt"
  163. if memo_path.exists():
  164. with open(memo_path, "r", encoding="utf-8") as fm:
  165. memo_content = fm.read().strip()
  166. results.append({
  167. "index": i,
  168. "id": idx_str,
  169. "requirement": req,
  170. "status": status,
  171. "has_strategy": has_strategy,
  172. "has_blueprint": has_blueprint,
  173. "has_caps": has_caps,
  174. "raw_cases_count": raw_cases_count,
  175. "memo": memo_content
  176. })
  177. return results
  178. @app.put("/api/requirements/{index}")
  179. def update_requirement(index: int, req: RequirementUpdateRequest):
  180. if not DB_PATH.exists():
  181. raise HTTPException(status_code=404, detail="db_requirements.json not found")
  182. with open(DB_PATH, "r", encoding="utf-8") as f:
  183. reqs = json.load(f)
  184. if index < 0 or index >= len(reqs):
  185. raise HTTPException(status_code=404, detail="Requirement index out of range")
  186. reqs[index] = req.requirement
  187. with open(DB_PATH, "w", encoding="utf-8") as f:
  188. json.dump(reqs, f, ensure_ascii=False, indent=2)
  189. return {"status": "success", "requirement": req.requirement}
  190. @app.post("/api/requirements/{index}/cases/filter")
  191. def filter_case(index: int, req: CaseFilterRequest):
  192. idx_str = f"{(index+1):03d}"
  193. dir_path = OUTPUT_DIR / idx_str / "raw_cases"
  194. source_path = dir_path / "source.json"
  195. filtered_path = dir_path / "filtered_cases.json"
  196. if not source_path.exists():
  197. raise HTTPException(status_code=404, detail="source.json not found")
  198. with open(source_path, "r", encoding="utf-8") as f:
  199. source_data = json.load(f)
  200. target_case = None
  201. new_sources = []
  202. for c in source_data.get("sources", []):
  203. c_id = c.get("case_id") or (c.get("_raw", {}).get("case_id")) or (c.get("post", {}).get("channel_content_id"))
  204. if c_id == req.case_id:
  205. target_case = c
  206. else:
  207. new_sources.append(c)
  208. if not target_case:
  209. raise HTTPException(status_code=404, detail="Case not found in source.json")
  210. source_data["sources"] = new_sources
  211. source_data["total"] = len(new_sources)
  212. with open(source_path, "w", encoding="utf-8") as f:
  213. json.dump(source_data, f, ensure_ascii=False, indent=2)
  214. filtered_data = {"total": 0, "by_reason": {}}
  215. if filtered_path.exists():
  216. with open(filtered_path, "r", encoding="utf-8") as f:
  217. filtered_data = json.load(f)
  218. reason = req.reason or "manual_delete"
  219. if reason not in filtered_data["by_reason"]:
  220. filtered_data["by_reason"][reason] = {"total": 0, "sources": []}
  221. # Copy and add filter_reason
  222. target_case["filter_reason"] = reason
  223. filtered_data["by_reason"][reason]["sources"].append(target_case)
  224. filtered_data["by_reason"][reason]["total"] = len(filtered_data["by_reason"][reason]["sources"])
  225. total_filtered = sum(r.get("total", 0) for r in filtered_data["by_reason"].values())
  226. filtered_data["total"] = total_filtered
  227. with open(filtered_path, "w", encoding="utf-8") as f:
  228. json.dump(filtered_data, f, ensure_ascii=False, indent=2)
  229. # Also attempt to remove from case.json if it exists
  230. case_path = dir_path.parent / "case.json"
  231. if case_path.exists():
  232. with open(case_path, "r", encoding="utf-8") as f:
  233. case_data = json.load(f)
  234. if "cases" in case_data:
  235. original_len = len(case_data["cases"])
  236. case_data["cases"] = [c for c in case_data["cases"] if c.get("case_id") != req.case_id and c.get("_raw", {}).get("case_id") != req.case_id]
  237. if len(case_data["cases"]) != original_len:
  238. with open(case_path, "w", encoding="utf-8") as f:
  239. json.dump(case_data, f, ensure_ascii=False, indent=2)
  240. return {"status": "success"}
  241. @app.post("/api/requirements/{index}/cases/restore")
  242. def restore_case(index: int, req: CaseFilterRequest):
  243. idx_str = f"{(index+1):03d}"
  244. dir_path = OUTPUT_DIR / idx_str / "raw_cases"
  245. source_path = dir_path / "source.json"
  246. filtered_path = dir_path / "filtered_cases.json"
  247. if not filtered_path.exists():
  248. raise HTTPException(status_code=404, detail="filtered_cases.json not found")
  249. with open(filtered_path, "r", encoding="utf-8") as f:
  250. filtered_data = json.load(f)
  251. target_case = None
  252. # Find and remove
  253. for reason, reason_obj in filtered_data.get("by_reason", {}).items():
  254. new_sources = []
  255. for c in reason_obj.get("sources", []):
  256. c_id = c.get("case_id") or (c.get("_raw", {}).get("case_id")) or (c.get("post", {}).get("channel_content_id"))
  257. if c_id == req.case_id:
  258. target_case = c
  259. else:
  260. new_sources.append(c)
  261. if target_case:
  262. reason_obj["sources"] = new_sources
  263. reason_obj["total"] = len(new_sources)
  264. break
  265. if not target_case:
  266. raise HTTPException(status_code=404, detail="Case not found in filtered_cases.json")
  267. total_filtered = sum(r.get("total", 0) for r in filtered_data.get("by_reason", {}).values())
  268. filtered_data["total"] = total_filtered
  269. with open(filtered_path, "w", encoding="utf-8") as f:
  270. json.dump(filtered_data, f, ensure_ascii=False, indent=2)
  271. source_data = {"total": 0, "sources": []}
  272. if source_path.exists():
  273. with open(source_path, "r", encoding="utf-8") as f:
  274. source_data = json.load(f)
  275. # Clean up filter_reason
  276. target_case.pop("filter_reason", None)
  277. source_data["sources"].append(target_case)
  278. source_data["total"] = len(source_data["sources"])
  279. with open(source_path, "w", encoding="utf-8") as f:
  280. json.dump(source_data, f, ensure_ascii=False, indent=2)
  281. # Note: We do not automatically inject it back into case.json because case.json
  282. # requires format normalization (which generate_case.py does). The user must rerun step 1.6.
  283. return {"status": "success"}
  284. @app.post("/api/requirements/{index}/upload_cluster")
  285. async def upload_cluster(index: int, file: UploadFile = File(...)):
  286. idx_str = f"{(index+1):03d}"
  287. dir_path = OUTPUT_DIR / idx_str
  288. dir_path.mkdir(parents=True, exist_ok=True)
  289. cluster_path = dir_path / "cluster.json"
  290. content = await file.read()
  291. try:
  292. json_data = json.loads(content)
  293. # Load existing data or start new list
  294. existing_data = []
  295. if cluster_path.exists():
  296. with open(cluster_path, "r", encoding="utf-8") as f:
  297. try:
  298. existing = json.load(f)
  299. if isinstance(existing, list):
  300. existing_data = existing
  301. else:
  302. existing_data = [existing] # Wrap old format
  303. except:
  304. pass
  305. existing_data.append(json_data)
  306. with open(cluster_path, "w", encoding="utf-8") as f:
  307. json.dump(existing_data, f, ensure_ascii=False, indent=2)
  308. return {"status": "success"}
  309. except Exception as e:
  310. raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
  311. @app.post("/api/requirements/{index}/save_cluster")
  312. async def save_cluster(index: int, request: Request):
  313. idx_str = f"{(index+1):03d}"
  314. dir_path = OUTPUT_DIR / idx_str
  315. dir_path.mkdir(parents=True, exist_ok=True)
  316. cluster_path = dir_path / "cluster.json"
  317. try:
  318. data = await request.json()
  319. with open(cluster_path, "w", encoding="utf-8") as f:
  320. json.dump(data, f, ensure_ascii=False, indent=2)
  321. return {"status": "success"}
  322. except Exception as e:
  323. raise HTTPException(status_code=400, detail=f"Failed to save JSON: {str(e)}")
  324. @app.post("/api/requirements")
  325. def add_requirement(req: RequirementUpdateRequest):
  326. if not DB_PATH.exists():
  327. raise HTTPException(status_code=404, detail="db_requirements.json not found")
  328. with open(DB_PATH, "r", encoding="utf-8") as f:
  329. reqs = json.load(f)
  330. reqs.append(req.requirement)
  331. new_index = len(reqs) - 1
  332. with open(DB_PATH, "w", encoding="utf-8") as f:
  333. json.dump(reqs, f, ensure_ascii=False, indent=2)
  334. # Pre-create output directory
  335. idx_str = f"{(new_index+1):03d}"
  336. dir_path = OUTPUT_DIR / idx_str
  337. dir_path.mkdir(parents=True, exist_ok=True)
  338. return {"status": "success", "index": new_index, "requirement": req.requirement}
  339. @app.get("/api/requirements/{index}/data")
  340. def get_requirement_data(index: int):
  341. idx_str = f"{(index+1):03d}"
  342. dir_path = OUTPUT_DIR / idx_str
  343. def safe_load_json(p: Path):
  344. if not p.exists():
  345. return None
  346. content = ""
  347. try:
  348. with open(p, "r", encoding="utf-8") as f:
  349. content = f.read()
  350. return json.loads(content)
  351. except Exception as e:
  352. return {"error": "Failed to parse JSON", "raw_content": content, "details": str(e)}
  353. data = {
  354. "strategy": safe_load_json(dir_path / "strategy.json"),
  355. "blueprint": safe_load_json(dir_path / "process.json") or safe_load_json(dir_path / "blueprint.json"),
  356. "blueprint_temp": safe_load_json(dir_path / "blueprint_temp.json"),
  357. "cluster": safe_load_json(dir_path / "cluster.json"),
  358. "capabilities": safe_load_json(dir_path / "capabilities.json") or safe_load_json(dir_path / "capabilities_extracted.json"),
  359. "capabilities_temp": safe_load_json(dir_path / "capabilities_temp.json"),
  360. "raw_cases": {
  361. "case": safe_load_json(dir_path / "case.json")
  362. }
  363. }
  364. raw_cases_dir = dir_path / "raw_cases"
  365. if raw_cases_dir.exists():
  366. for f in raw_cases_dir.glob("*.json"):
  367. data["raw_cases"][f.stem] = safe_load_json(f)
  368. return data
  369. @app.get("/api/requirements/{index}/memo")
  370. def get_memo(index: int):
  371. idx_str = f"{(index+1):03d}"
  372. memo_path = OUTPUT_DIR / idx_str / "memo.txt"
  373. if memo_path.exists():
  374. with open(memo_path, "r", encoding="utf-8") as f:
  375. return {"memo": f.read()}
  376. return {"memo": ""}
  377. @app.get("/api/requirements/{index}/pipeline-status")
  378. def get_pipeline_status(index: int):
  379. idx_str = f"{(index+1):03d}"
  380. dir_path = OUTPUT_DIR / idx_str
  381. raw_cases_dir = dir_path / "raw_cases"
  382. status = {
  383. "research": False,
  384. "source": False,
  385. "generate-case": False,
  386. "workflow-extract": False,
  387. "capability-extract-1": False,
  388. "process-cluster": False,
  389. "process-score": False,
  390. "capability-extract": False,
  391. "capability-enrich": False,
  392. "strategy": False
  393. }
  394. if raw_cases_dir.exists():
  395. if list(raw_cases_dir.glob("case_*.json")):
  396. status["research"] = True
  397. if (raw_cases_dir / "source.json").exists():
  398. status["source"] = True
  399. case_file = dir_path / "case.json"
  400. legacy_detailed = raw_cases_dir / "case_detailed.json"
  401. has_workflow = False
  402. has_capability = False
  403. if case_file.exists():
  404. status["generate-case"] = True
  405. try:
  406. with open(case_file, "r", encoding="utf-8") as f:
  407. cdata = json.load(f)
  408. if cdata.get("cases"):
  409. for c in cdata["cases"]:
  410. if c.get("workflow_groups"):
  411. has_workflow = True
  412. if any(
  413. isinstance(group, dict)
  414. and group.get("capability")
  415. for group in (c.get("workflow_groups") or [])
  416. ):
  417. has_capability = True
  418. if has_workflow and has_capability:
  419. break
  420. except Exception:
  421. pass
  422. if legacy_detailed.exists():
  423. status["generate-case"] = True
  424. has_workflow = True
  425. if has_workflow:
  426. status["workflow-extract"] = True
  427. if has_capability:
  428. status["capability-extract-1"] = True
  429. if (dir_path / "blueprint_temp.json").exists():
  430. status["process-cluster"] = True
  431. if (dir_path / "process.json").exists():
  432. status["process-score"] = True
  433. if (dir_path / "capabilities_temp.json").exists():
  434. status["capability-extract"] = True
  435. if (dir_path / "capabilities.json").exists():
  436. status["capability-enrich"] = True
  437. if (dir_path / "strategy.json").exists():
  438. status["strategy"] = True
  439. return status
  440. @app.post("/api/requirements/{index}/memo")
  441. def save_memo(index: int, req: MemoRequest):
  442. idx_str = f"{(index+1):03d}"
  443. dir_path = OUTPUT_DIR / idx_str
  444. dir_path.mkdir(parents=True, exist_ok=True)
  445. memo_path = dir_path / "memo.txt"
  446. with open(memo_path, "w", encoding="utf-8") as f:
  447. f.write(req.memo)
  448. return {"status": "ok"}
  449. @app.get("/api/requirements/{index}/files")
  450. def list_req_files(index: int):
  451. idx_str = f"{(index+1):03d}"
  452. dir_path = OUTPUT_DIR / idx_str
  453. if not dir_path.exists():
  454. return {"files": []}
  455. files = []
  456. for p in dir_path.rglob("*.*"):
  457. if p.is_file() and p.name not in [".DS_Store"]:
  458. try:
  459. rel_path = p.relative_to(dir_path)
  460. files.append({
  461. "path": str(rel_path).replace("\\", "/"),
  462. "name": p.name,
  463. "size": p.stat().st_size,
  464. "mtime": p.stat().st_mtime
  465. })
  466. except:
  467. pass
  468. return {"files": files}
  469. @app.get("/api/requirements/{index}/files/raw")
  470. def get_req_file_raw(index: int, path: str):
  471. idx_str = f"{(index+1):03d}"
  472. if ".." in path or path.startswith("/"):
  473. raise HTTPException(status_code=400, detail="Invalid path")
  474. file_path = OUTPUT_DIR / idx_str / path
  475. if not file_path.exists() or not file_path.is_file():
  476. raise HTTPException(status_code=404, detail="File not found")
  477. from fastapi.responses import FileResponse
  478. return FileResponse(file_path)
  479. @app.get("/api/requirements/{index}/files/content")
  480. def get_req_file_content(index: int, path: str):
  481. idx_str = f"{(index+1):03d}"
  482. # prevent path traversal
  483. if ".." in path or path.startswith("/"):
  484. raise HTTPException(status_code=400, detail="Invalid path")
  485. file_path = OUTPUT_DIR / idx_str / path
  486. if not file_path.exists() or not file_path.is_file():
  487. raise HTTPException(status_code=404, detail="File not found")
  488. try:
  489. content = file_path.read_text(encoding="utf-8", errors="replace")
  490. return {"content": content}
  491. except Exception as e:
  492. return {"content": f"Unable to read file: {e}"}
  493. def parse_script_args(script_path: Path):
  494. args = []
  495. raw_help = ""
  496. try:
  497. import subprocess
  498. import os
  499. is_python = script_path.name.endswith('.py')
  500. parsed_via_ast = False
  501. env = os.environ.copy()
  502. project_root = str(BASE_DIR.parent.parent)
  503. if "PYTHONPATH" in env:
  504. env["PYTHONPATH"] = project_root + os.pathsep + env["PYTHONPATH"]
  505. else:
  506. env["PYTHONPATH"] = project_root
  507. if is_python:
  508. INSPECTOR_CODE = """
  509. import sys, json, argparse, runpy
  510. def _mock_parse_args(self, args=None, namespace=None):
  511. actions_info = []
  512. for action in self._actions:
  513. if action.dest == 'help': continue
  514. actions_info.append({
  515. 'names': action.option_strings if action.option_strings else [action.dest],
  516. 'dest': action.dest,
  517. 'required': action.required,
  518. 'default': action.default if action.default is not argparse.SUPPRESS else None,
  519. 'desc': action.help,
  520. 'type': str(action.type.__name__) if hasattr(action.type, '__name__') else str(action.type),
  521. 'action_type': action.__class__.__name__,
  522. 'choices': action.choices,
  523. 'is_positional': not action.option_strings
  524. })
  525. print('ARGPARSE_METADATA_START')
  526. print(json.dumps(actions_info, ensure_ascii=False))
  527. print('ARGPARSE_METADATA_END')
  528. sys.exit(0)
  529. argparse.ArgumentParser.parse_args = _mock_parse_args
  530. try:
  531. runpy.run_path(sys.argv[1], run_name='__main__')
  532. except SystemExit:
  533. pass
  534. except Exception:
  535. pass
  536. """
  537. result = subprocess.run(
  538. [sys.executable, "-c", INSPECTOR_CODE, str(script_path)],
  539. capture_output=True, text=True, encoding="utf-8", timeout=5, env=env
  540. )
  541. out = result.stdout
  542. if 'ARGPARSE_METADATA_START' in out and 'ARGPARSE_METADATA_END' in out:
  543. json_str = out.split('ARGPARSE_METADATA_START')[1].split('ARGPARSE_METADATA_END')[0].strip()
  544. args_data = json.loads(json_str)
  545. for action in args_data:
  546. args.append({
  547. "names": action["names"],
  548. "desc": action["desc"],
  549. "required": action["required"],
  550. "default": action["default"],
  551. "choices": action["choices"],
  552. "is_positional": action["is_positional"],
  553. "action_type": action["action_type"]
  554. })
  555. parsed_via_ast = True
  556. if not parsed_via_ast:
  557. result = subprocess.run(
  558. [sys.executable if is_python else "bash", str(script_path), "--help"],
  559. capture_output=True, text=True, encoding="utf-8", timeout=5, env=env
  560. )
  561. help_text = result.stdout + result.stderr
  562. raw_help = help_text
  563. current_arg = None
  564. for line in help_text.splitlines():
  565. stripped = line.strip()
  566. if stripped.startswith("-") and not stripped.startswith("--help"):
  567. parts = stripped.split(" ")
  568. arg_name_part = parts[0].strip()
  569. desc = parts[-1].strip() if len(parts) > 1 else ""
  570. names = [x.strip().rstrip(",") for x in arg_name_part.split() if x.startswith("-") or x.startswith("--")]
  571. if names:
  572. current_arg = {
  573. "names": names,
  574. "desc": desc,
  575. "required": False,
  576. "default": None,
  577. "is_positional": False
  578. }
  579. args.append(current_arg)
  580. elif current_arg and stripped and not stripped.startswith("-"):
  581. if current_arg["desc"]:
  582. current_arg["desc"] += " " + stripped
  583. else:
  584. current_arg["desc"] = stripped
  585. except Exception as e:
  586. raw_help = f"Failed to parse help: {e}"
  587. args = []
  588. return args, raw_help
  589. @app.get("/api/scripts")
  590. async def list_scripts():
  591. scripts = []
  592. if SCRATCH_DIR.exists():
  593. for d in SCRATCH_DIR.iterdir():
  594. if d.is_dir():
  595. main_scripts = list(d.glob("*.py")) + list(d.glob("*.sh"))
  596. for s in main_scripts:
  597. scripts.append({"name": s.name, "folder": d.name})
  598. return {"scripts": scripts}
  599. @app.get("/api/scripts/{folder}/{filename}/parse")
  600. async def parse_existing_script(folder: str, filename: str):
  601. script_path = SCRATCH_DIR / folder / filename
  602. if not script_path.exists():
  603. raise HTTPException(status_code=404, detail="Script not found")
  604. args, raw_help = parse_script_args(script_path)
  605. return {"status": "success", "filename": filename, "folder": folder, "args": args, "raw_help": raw_help}
  606. @app.post("/api/scripts/upload")
  607. async def upload_script(file: UploadFile = File(...)):
  608. content = await file.read()
  609. script_stem = Path(file.filename).stem
  610. script_dir = SCRATCH_DIR / script_stem
  611. script_dir.mkdir(parents=True, exist_ok=True)
  612. script_path = script_dir / file.filename
  613. script_path.write_bytes(content)
  614. args, raw_help = parse_script_args(script_path)
  615. return {"status": "success", "filename": file.filename, "folder": script_stem, "args": args, "raw_help": raw_help}
  616. RUNNING_PROCESSES = {}
  617. @app.post("/api/scripts/run")
  618. async def run_script(req: RunScriptRequest):
  619. script_dir = SCRATCH_DIR / req.folder
  620. script_path = script_dir / req.filename
  621. if not script_path.exists():
  622. raise HTTPException(status_code=404, detail="Script not found")
  623. (script_dir / "inputs").mkdir(exist_ok=True)
  624. (script_dir / "outputs").mkdir(exist_ok=True)
  625. cmd = [sys.executable if req.filename.endswith('.py') else "bash", str(script_path)]
  626. for arg in req.args:
  627. name = arg.get("name")
  628. val = arg.get("value")
  629. is_pos = arg.get("is_positional", False)
  630. if is_pos:
  631. if isinstance(val, str) and val.strip():
  632. # Positional arguments split by space to simulate shell splitting
  633. cmd.extend(val.split())
  634. else:
  635. if not name:
  636. continue
  637. if isinstance(val, bool):
  638. if val:
  639. cmd.append(name)
  640. else:
  641. if str(val).strip():
  642. cmd.append(name)
  643. cmd.append(str(val))
  644. import time
  645. import os
  646. start_time = time.time()
  647. env = os.environ.copy()
  648. project_root = str(BASE_DIR.parent.parent)
  649. if "PYTHONPATH" in env:
  650. env["PYTHONPATH"] = project_root + os.pathsep + env["PYTHONPATH"]
  651. else:
  652. env["PYTHONPATH"] = project_root
  653. env["PYTHONUNBUFFERED"] = "1"
  654. async def generate_output():
  655. import asyncio
  656. import json
  657. import time
  658. start_time = time.time()
  659. try:
  660. process = await asyncio.create_subprocess_exec(
  661. *cmd,
  662. cwd=str(script_dir),
  663. stdout=asyncio.subprocess.PIPE,
  664. stderr=asyncio.subprocess.PIPE,
  665. env=env,
  666. # On Windows, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP allows sending CTRL_BREAK_EVENT
  667. )
  668. RUNNING_PROCESSES[req.folder] = process
  669. q = asyncio.Queue()
  670. async def enqueue_stream(stream, stream_type):
  671. while True:
  672. line = await stream.readline()
  673. if not line:
  674. break
  675. await q.put(json.dumps({stream_type: line.decode('utf-8', errors='replace')}) + "\n")
  676. await q.put(None)
  677. asyncio.create_task(enqueue_stream(process.stdout, "stdout"))
  678. asyncio.create_task(enqueue_stream(process.stderr, "stderr"))
  679. eof_count = 0
  680. while eof_count < 2:
  681. item = await q.get()
  682. if item is None:
  683. eof_count += 1
  684. else:
  685. yield item
  686. await process.wait()
  687. generated_files = []
  688. for p in script_dir.iterdir():
  689. if p.is_file() and p.stat().st_mtime >= start_time - 2:
  690. if p.name != req.filename:
  691. generated_files.append({"name": f"{req.folder}/{p.name}", "size": p.stat().st_size})
  692. yield json.dumps({"returncode": process.returncode, "files": generated_files}) + "\n"
  693. except asyncio.CancelledError:
  694. if process.returncode is None:
  695. try:
  696. process.terminate()
  697. except Exception:
  698. pass
  699. raise
  700. except Exception as e:
  701. yield json.dumps({"stderr": str(e), "returncode": -1, "files": []}) + "\n"
  702. finally:
  703. if req.folder in RUNNING_PROCESSES:
  704. del RUNNING_PROCESSES[req.folder]
  705. if process.returncode is None:
  706. try:
  707. process.terminate()
  708. except Exception:
  709. pass
  710. from fastapi.responses import StreamingResponse
  711. return StreamingResponse(generate_output(), media_type="application/x-ndjson")
  712. @app.post("/api/scripts/{folder}/stop")
  713. async def stop_script(folder: str):
  714. if folder in RUNNING_PROCESSES:
  715. process = RUNNING_PROCESSES[folder]
  716. if process.returncode is None:
  717. try:
  718. import psutil
  719. parent = psutil.Process(process.pid)
  720. for child in parent.children(recursive=True):
  721. child.kill()
  722. parent.kill()
  723. except Exception:
  724. try:
  725. process.terminate()
  726. except Exception:
  727. pass
  728. return {"status": "success", "message": "Process stopped"}
  729. return {"status": "not_running"}
  730. @app.get("/api/scripts/files/{filename:path}")
  731. async def download_script_file(filename: str, inline: bool = False):
  732. if ".." in filename or filename.startswith("/"):
  733. raise HTTPException(status_code=400, detail="Invalid filename")
  734. file_path = SCRATCH_DIR / filename
  735. if not file_path.exists() or not file_path.is_file():
  736. raise HTTPException(status_code=404, detail="File not found")
  737. from fastapi.responses import FileResponse
  738. if inline:
  739. return FileResponse(file_path, content_disposition_type="inline")
  740. return FileResponse(file_path, filename=file_path.name)
  741. @app.delete("/api/scripts/files/{filename:path}")
  742. async def delete_script_file(filename: str):
  743. if ".." in filename or filename.startswith("/"):
  744. raise HTTPException(status_code=400, detail="Invalid filename")
  745. file_path = SCRATCH_DIR / filename
  746. if not file_path.exists() or not file_path.is_file():
  747. raise HTTPException(status_code=404, detail="File not found")
  748. file_path.unlink()
  749. return {"status": "success"}
  750. @app.get("/api/scripts/{folder}/files")
  751. async def list_script_folder_files(folder: str):
  752. folder_path = SCRATCH_DIR / folder
  753. if not folder_path.exists() or not folder_path.is_dir():
  754. return {"files": []}
  755. files = []
  756. for f in folder_path.rglob("*"):
  757. if f.is_file():
  758. # Calculate relative path to the scratch dir for the download URL
  759. rel_path = f.relative_to(SCRATCH_DIR)
  760. files.append({
  761. "name": f.name,
  762. "path": str(rel_path).replace("\\", "/"),
  763. "size": f.stat().st_size
  764. })
  765. return {"files": files}
  766. import shutil
  767. @app.delete("/api/scripts/{folder}")
  768. async def delete_script(folder: str):
  769. if ".." in folder or folder.startswith("/"):
  770. raise HTTPException(status_code=400, detail="Invalid folder")
  771. script_dir = SCRATCH_DIR / folder
  772. if script_dir.exists() and script_dir.is_dir():
  773. shutil.rmtree(script_dir)
  774. return {"status": "success"}
  775. @app.post("/api/scripts/{folder}/upload_data")
  776. async def upload_script_data(folder: str, file: UploadFile = File(...)):
  777. if ".." in folder or folder.startswith("/"):
  778. raise HTTPException(status_code=400, detail="Invalid folder")
  779. script_dir = SCRATCH_DIR / folder
  780. if not script_dir.exists() or not script_dir.is_dir():
  781. raise HTTPException(status_code=404, detail="Script folder not found")
  782. inputs_dir = script_dir / "inputs"
  783. inputs_dir.mkdir(exist_ok=True)
  784. file_path = inputs_dir / file.filename
  785. content = await file.read()
  786. file_path.write_bytes(content)
  787. return {"status": "success", "filename": f"inputs/{file.filename}"}
  788. @app.get("/api/prompts")
  789. def list_prompts():
  790. if not PROMPTS_DIR.exists():
  791. return []
  792. return [f.name for f in PROMPTS_DIR.glob("*.prompt")]
  793. @app.get("/api/prompts/{name}")
  794. def get_prompt(name: str):
  795. if "/" in name or "\\" in name:
  796. raise HTTPException(status_code=400, detail="Invalid prompt name")
  797. prompt_path = PROMPTS_DIR / name
  798. if not prompt_path.exists() or not prompt_path.is_file():
  799. raise HTTPException(status_code=404, detail="Prompt not found")
  800. schema_name = name.replace(".prompt", ".schema.json")
  801. schema_path = PROMPTS_DIR / schema_name
  802. schema_content = ""
  803. if schema_path.exists() and schema_path.is_file():
  804. with open(schema_path, "r", encoding="utf-8") as f:
  805. schema_content = f.read()
  806. with open(prompt_path, "r", encoding="utf-8") as f:
  807. return {"content": f.read(), "schema_content": schema_content}
  808. @app.post("/api/prompts/{name}")
  809. def save_prompt(name: str, req: PromptRequest):
  810. if "/" in name or "\\" in name:
  811. raise HTTPException(status_code=400, detail="Invalid prompt name")
  812. prompt_path = PROMPTS_DIR / name
  813. if not prompt_path.exists() or not prompt_path.is_file():
  814. raise HTTPException(status_code=404, detail="Prompt not found")
  815. schema_name = name.replace(".prompt", ".schema.json")
  816. schema_path = PROMPTS_DIR / schema_name
  817. if req.schema_content is not None:
  818. if req.schema_content.strip():
  819. try:
  820. parsed_schema = json.loads(req.schema_content)
  821. except Exception as e:
  822. raise HTTPException(status_code=400, detail=f"Invalid Schema JSON: {str(e)}")
  823. with open(schema_path, "w", encoding="utf-8") as f:
  824. json.dump(parsed_schema, f, ensure_ascii=False, indent=2)
  825. else:
  826. if schema_path.exists():
  827. schema_path.unlink() # remove if empty
  828. with open(prompt_path, "w", encoding="utf-8", newline="\n") as f:
  829. # Enforce Unix LF line endings to avoid unnecessary git diffs
  830. # and format inconsistencies between the file system and UI.
  831. f.write(req.content.replace('\r\n', '\n'))
  832. return {"status": "ok"}
  833. @app.post("/api/prompts/{name}/update_schema")
  834. async def api_update_schema(name: str):
  835. if "/" in name or "\\" in name:
  836. raise HTTPException(status_code=400, detail="Invalid prompt name")
  837. prompt_name = name.replace(".prompt", "")
  838. from script.update_schema import update_schema as script_update_schema
  839. try:
  840. # Calls the script to update the schema (this writes to the file)
  841. await script_update_schema(prompt_name=prompt_name, model="openai/gpt-5.4", dry_run=False)
  842. schema_name = name.replace(".prompt", ".schema.json")
  843. schema_path = PROMPTS_DIR / schema_name
  844. schema_content = ""
  845. if schema_path.exists() and schema_path.is_file():
  846. with open(schema_path, "r", encoding="utf-8") as f:
  847. schema_content = f.read()
  848. return {"status": "ok", "schema_content": schema_content}
  849. except Exception as e:
  850. import traceback
  851. traceback.print_exc()
  852. raise HTTPException(status_code=500, detail=f"Failed to update schema: {str(e)}")
  853. async def run_pipeline_task(index: int, run_req: RunRequest):
  854. run_state = active_runs[index]
  855. run_state.status = "running"
  856. dir_path = OUTPUT_DIR / f"{(index+1):03d}"
  857. # We no longer manually delete files here for most modes,
  858. # run_pipeline.py handles overwriting when --only-step or --start-from is passed.
  859. # build command
  860. script_path = BASE_DIR / "run_pipeline.py"
  861. cmd = [sys.executable, str(script_path), "--index", str(index)]
  862. if run_req.platforms:
  863. cmd.extend(["--platforms", run_req.platforms])
  864. if run_req.use_claude_sdk:
  865. cmd.append("--use-claude-sdk")
  866. if run_req.model:
  867. cmd.extend(["--model", run_req.model])
  868. if run_req.only_step:
  869. cmd.extend(["--only-step", run_req.only_step])
  870. if run_req.case_index is not None:
  871. cmd.extend(["--case-index", str(run_req.case_index)])
  872. elif run_req.phase is not None:
  873. cmd.extend(["--phase", str(run_req.phase)])
  874. else:
  875. if run_req.start_from:
  876. cmd.extend(["--start-from", run_req.start_from])
  877. if run_req.end_at:
  878. cmd.extend(["--end-at", run_req.end_at])
  879. if run_req.restart_mode and run_req.restart_mode == "smart":
  880. pass # Smart mode requires no extra args
  881. run_state.logs.append(f"Starting command: {' '.join(cmd)}\n")
  882. import threading
  883. import subprocess
  884. import os
  885. def run_process():
  886. try:
  887. process = subprocess.Popen(
  888. cmd,
  889. stdout=subprocess.PIPE,
  890. stderr=subprocess.STDOUT,
  891. text=True,
  892. encoding="utf-8",
  893. bufsize=1,
  894. cwd=str(BASE_DIR),
  895. env=dict(os.environ, PYTHONIOENCODING="utf-8")
  896. )
  897. run_state.process = process
  898. for line in iter(process.stdout.readline, ''):
  899. if line:
  900. run_state.logs.append(line)
  901. process.stdout.close()
  902. return_code = process.wait()
  903. run_state.logs.append(f"\nProcess exited with code {return_code}")
  904. run_state.status = "completed" if return_code == 0 else "failed"
  905. except Exception as e:
  906. run_state.logs.append(f"\nException occurred: {repr(e)}")
  907. run_state.status = "failed"
  908. thread = threading.Thread(target=run_process)
  909. thread.start()
  910. @app.post("/api/pipeline/run/{index}")
  911. async def trigger_pipeline(index: int, req: RunRequest):
  912. if index in active_runs and active_runs[index].status == "running":
  913. raise HTTPException(status_code=400, detail="Pipeline already running for this index")
  914. active_runs[index] = ActiveRun()
  915. asyncio.create_task(run_pipeline_task(index, req))
  916. return {"message": "Pipeline started", "index": index}
  917. @app.post("/api/pipeline/stop/{index}")
  918. async def stop_pipeline(index: int):
  919. if index not in active_runs or active_runs[index].status != "running":
  920. raise HTTPException(status_code=400, detail="No running pipeline found for this index")
  921. process = active_runs[index].process
  922. if process:
  923. try:
  924. process.terminate()
  925. active_runs[index].logs.append("\n[System] 🛑 Pipeline forcefully terminated by user.\n")
  926. active_runs[index].status = "failed"
  927. return {"status": "stopped"}
  928. except Exception as e:
  929. raise HTTPException(status_code=500, detail=f"Failed to terminate: {str(e)}")
  930. raise HTTPException(status_code=500, detail="Process handle missing")
  931. @app.get("/api/pipeline/status")
  932. def get_all_status():
  933. res = {}
  934. for idx, run in active_runs.items():
  935. res[idx] = {
  936. "status": run.status,
  937. "start_time": run.start_time,
  938. "logs": list(run.logs)
  939. }
  940. return res
  941. # Mount UI static files
  942. ui_dir = BASE_DIR / "ui"
  943. ui_dir.mkdir(exist_ok=True)
  944. app.mount("/static", StaticFiles(directory=str(ui_dir)), name="static")
  945. # Mount output directory for local resources (like images)
  946. app.mount("/output", StaticFiles(directory=str(OUTPUT_DIR)), name="output")
  947. @app.get("/")
  948. def serve_ui():
  949. index_html = ui_dir / "index.html"
  950. if not index_html.exists():
  951. return HTMLResponse("UI not found. Please create ui/index.html", status_code=404)
  952. with open(index_html, "r", encoding="utf-8") as f:
  953. return HTMLResponse(f.read())
  954. if __name__ == "__main__":
  955. print("Starting Pipeline Dashboard server on http://127.0.0.0:18080")
  956. uvicorn.run("server:app", host="0.0.0.0", port=18080, reload=False)