runtime_files.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. from __future__ import annotations
  2. import json
  3. import shutil
  4. from pathlib import Path
  5. from typing import Any
  6. RUNTIME_FILENAMES = [
  7. "source_context.json",
  8. "pattern_seed_pack.json",
  9. "search_queries.jsonl",
  10. "discovered_content_items.jsonl",
  11. "content_media_records.jsonl",
  12. "pattern_recall_evidence.jsonl",
  13. "rule_decisions.jsonl",
  14. "walk_actions.jsonl",
  15. "run_events.jsonl",
  16. "source_path_records.jsonl",
  17. "search_clues.jsonl",
  18. "final_output.json",
  19. "strategy_review.json",
  20. ]
  21. class LocalRuntimeFileStore:
  22. def __init__(self, base_dir: Path | str = Path("runtime/v1")) -> None:
  23. self.base_dir = Path(base_dir)
  24. self._media_pipeline_tasks: dict[str, dict[str, Any]] = {}
  25. self._media_pipeline_task_ids_by_key: dict[str, str] = {}
  26. def prepare_run(self, run_id: str) -> Path:
  27. path = self.run_dir(run_id)
  28. if path.exists():
  29. raise FileExistsError(f"run already exists: {run_id}")
  30. path.mkdir(parents=True, exist_ok=True)
  31. return path
  32. def run_dir(self, run_id: str) -> Path:
  33. return self.base_dir / run_id
  34. def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  35. path = self.run_dir(run_id) / filename
  36. path.parent.mkdir(parents=True, exist_ok=True)
  37. path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  38. return path
  39. def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  40. return self.write_json(run_id, filename, data)
  41. def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
  42. path = self.run_dir(run_id) / filename
  43. path.parent.mkdir(parents=True, exist_ok=True)
  44. if filename in {"content_media_records.jsonl", "pattern_recall_evidence.jsonl", "search_queries.jsonl"}:
  45. rows = _replace_keyed_rows(
  46. self.read_jsonl(run_id, filename),
  47. rows,
  48. _jsonl_key_fields(filename),
  49. )
  50. path.write_text(
  51. "".join(
  52. json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
  53. for row in rows
  54. ),
  55. encoding="utf-8",
  56. )
  57. return path
  58. with path.open("a", encoding="utf-8") as file:
  59. for row in rows:
  60. file.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
  61. return path
  62. def read_json(self, run_id: str, filename: str) -> dict[str, Any]:
  63. path = self.run_dir(run_id) / filename
  64. return json.loads(path.read_text(encoding="utf-8"))
  65. def read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  66. path = self.run_dir(run_id) / filename
  67. if not path.exists():
  68. return []
  69. return [
  70. json.loads(line)
  71. for line in path.read_text(encoding="utf-8").splitlines()
  72. if line.strip()
  73. ]
  74. def file_status(self, run_id: str) -> dict[str, bool]:
  75. run_dir = self.run_dir(run_id)
  76. return {filename: (run_dir / filename).exists() for filename in RUNTIME_FILENAMES}
  77. def list_runs(self) -> list[str]:
  78. if not self.base_dir.exists():
  79. return []
  80. return sorted(path.name for path in self.base_dir.iterdir() if path.is_dir())
  81. def create_run_record(self, record: dict[str, Any]) -> None:
  82. return None
  83. def update_run_record(self, run_id: str, updates: dict[str, Any]) -> None:
  84. return None
  85. def record_policy_run(self, record: dict[str, Any]) -> None:
  86. return None
  87. def append_run_event_records(
  88. self,
  89. run_id: str,
  90. policy_run_id: str,
  91. rows: list[dict[str, Any]],
  92. ) -> None:
  93. return None
  94. def write_publish_jobs(
  95. self,
  96. run_id: str,
  97. policy_run_id: str,
  98. rows: list[dict[str, Any]],
  99. ) -> None:
  100. return None
  101. def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
  102. return None
  103. def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
  104. return None
  105. def write_search_clue_assets(self, rows: list[dict[str, Any]]) -> None:
  106. return None
  107. def write_search_clue_asset_evidence(self, rows: list[dict[str, Any]]) -> None:
  108. return None
  109. def read_performance_feedback(
  110. self,
  111. run_id: str,
  112. policy_run_id: str,
  113. ) -> list[dict[str, Any]]:
  114. return []
  115. def enqueue_media_pipeline_task(self, task: dict[str, Any]) -> dict[str, Any]:
  116. idempotency_key = str(task["idempotency_key"])
  117. existing_id = self._media_pipeline_task_ids_by_key.get(idempotency_key)
  118. if existing_id:
  119. return dict(self._media_pipeline_tasks[existing_id])
  120. stored = dict(task)
  121. self._media_pipeline_tasks[str(stored["task_id"])] = stored
  122. self._media_pipeline_task_ids_by_key[idempotency_key] = str(stored["task_id"])
  123. return dict(stored)
  124. def update_media_pipeline_task(self, task_id: str, updates: dict[str, Any]) -> None:
  125. existing = self._media_pipeline_tasks.get(str(task_id))
  126. if not existing:
  127. return
  128. existing.update(updates)
  129. def read_media_pipeline_tasks(
  130. self,
  131. run_id: str,
  132. *,
  133. batch_id: str | None = None,
  134. ) -> list[dict[str, Any]]:
  135. rows = [
  136. dict(row)
  137. for row in self._media_pipeline_tasks.values()
  138. if row.get("run_id") == run_id
  139. ]
  140. if batch_id is not None:
  141. rows = [row for row in rows if row.get("batch_id") == batch_id]
  142. return sorted(rows, key=lambda row: str(row.get("task_id") or ""))
  143. def _replace_keyed_rows(
  144. existing_rows: list[dict[str, Any]],
  145. new_rows: list[dict[str, Any]],
  146. key_fields: tuple[str, ...],
  147. ) -> list[dict[str, Any]]:
  148. keyed_rows: dict[tuple[Any, ...], dict[str, Any]] = {}
  149. order: list[tuple[Any, ...]] = []
  150. for row in [*existing_rows, *new_rows]:
  151. key = tuple(row.get(field) for field in key_fields)
  152. if key not in keyed_rows:
  153. order.append(key)
  154. keyed_rows[key] = row
  155. return [keyed_rows[key] for key in order]
  156. def _jsonl_key_fields(filename: str) -> tuple[str, ...]:
  157. if filename == "pattern_recall_evidence.jsonl":
  158. return ("run_id", "policy_run_id", "recall_evidence_id")
  159. if filename == "search_queries.jsonl":
  160. return ("run_id", "policy_run_id", "search_query_id")
  161. if filename == "content_media_records.jsonl":
  162. return ("run_id", "policy_run_id", "platform", "platform_content_id")
  163. raise ValueError(f"unsupported keyed JSONL file: {filename}")