oc_chat
Loading...
Searching...
No Matches
ingest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/ingest.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_ingest_idempotence.py"
6"""
7Idempotent ROADMAP corpus ingest into the registry.
8
9Purpose
10-------
11Populate the roadmap v0.3 registry tables from the on-disk ROADMAP corpus.
12
13Responsibilities
14----------------
15- Walk and parse the four ROADMAP artifact families.
16- Seed static version, phase, track, and render-section metadata.
17- Emit roadmap item and dependency operations only for semantic changes.
18
19Diagnostics
20-----------
21Domain: OC_CHAT.MAIN
22Levels:
23 L2 - ingest lifecycle
24 L3 - file and row counts
25 L4 - per-record idempotence decisions
26
27Contracts
28---------
29- Re-running against unchanged inputs creates no new operation_log rows.
30- Dry-run reports pending changes without writing registry rows.
31
32Dependencies
33------------
34- oc_chat.registry.roadmap.parser (markdown parsing)
35- oc_chat.registry.roadmap.operations (mutating seam helpers)
36- oc_chat.registry.api (Registry facade the operations are executed through)
37- oc_chat.registry.backends.sqlite (SQLiteBackend connections and transactions for the metadata upserts)
38- oc_chat.registry.canonical (canonical JSON for the idempotence hash)
39- oc_diagnostics (structured debug logging via _dbg)
40"""
41from __future__ import annotations
42
43from dataclasses import dataclass
44from pathlib import Path
45import sqlite3
46from typing import Mapping
47
48from oc_diagnostics import _dbg
49
50from oc_chat.registry.api import Registry
51from oc_chat.registry.backends.sqlite import SQLiteBackend
52from oc_chat.registry.canonical import canonical_json
54 roadmap_item_add_dependency,
55 roadmap_item_create,
56)
58 ParsedItem,
59 ParsedPhase,
60 ParsedRoadmap,
61 ParsedRenderSection,
62 ParsedTrack,
63 parse_roadmap_file,
64)
65
66_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
67
68
69@dataclass(frozen=True)
71 ok: bool
72 dry_run: bool
73 files: int
74 created: int
75 updated: int
76 skipped: int
77 errors: tuple[str, ...] = ()
78 warnings: tuple[str, ...] = ()
79
80 def to_dict(self) -> dict[str, object]:
81 return {
82 "ok": self.ok,
83 "dry_run": self.dry_run,
84 "files": self.files,
85 "created": self.created,
86 "updated": self.updated,
87 "skipped": self.skipped,
88 "errors": list(self.errors),
89 "warnings": list(self.warnings),
90 }
91
92
94 registry: Registry,
95 roadmap_dir: str | Path,
96 *,
97 actor_slot: str | None = None,
98 actor_type: str = "human_operator",
99 dry_run: bool = False,
100) -> IngestResult:
101 """Parse and ingest every ROADMAP artifact under *roadmap_dir*."""
102
103 backend = _sqlite_backend(registry)
104 slot = actor_slot or backend.active_operator_slot()
105 if slot is None:
106 return IngestResult(
107 ok=False,
108 dry_run=dry_run,
109 files=0,
110 created=0,
111 updated=0,
112 skipped=0,
113 errors=("no active operator slot exists",),
114 )
115 path = Path(roadmap_dir)
116 files = _roadmap_files(path)
117 parsed: list[ParsedRoadmap] = []
118 errors: list[str] = []
119 for file_path in files:
120 try:
121 parsed.append(parse_roadmap_file(file_path))
122 except Exception as exc:
123 errors.append(f"{file_path}: {exc}")
124 if errors:
125 return IngestResult(
126 ok=False,
127 dry_run=dry_run,
128 files=len(files),
129 created=0,
130 updated=0,
131 skipped=0,
132 errors=tuple(errors),
133 )
134
135 created = 0
136 updated = 0
137 skipped = 0
138 if dry_run:
139 with backend.connect() as conn:
140 for roadmap in parsed:
141 for item in roadmap.items:
142 action = _item_action(conn, item)
143 if action == "create":
144 created += 1
145 elif action == "update":
146 updated += 1
147 else:
148 skipped += 1
149 return IngestResult(
150 ok=True,
151 dry_run=True,
152 files=len(files),
153 created=created,
154 updated=updated,
155 skipped=skipped,
156 )
157
158 with backend.transaction("immediate") as conn:
159 _upsert_static_metadata(conn, parsed, backend.clock.now_wall())
160
161 for roadmap in parsed:
162 for item in roadmap.items:
163 with backend.connect() as conn:
164 action = _item_action(conn, item)
165 if action == "skip":
166 skipped += 1
167 continue
168 roadmap_item_create(
169 registry,
170 item_id=item.item_id,
171 title=item.title,
172 track_kind=item.track_kind, # type: ignore[arg-type]
173 phase_code=item.phase_code,
174 parent_item_id=item.parent_item_id,
175 prose=item.prose,
176 status=item.status, # type: ignore[arg-type]
177 priority=item.priority, # type: ignore[arg-type]
178 sort_order=item.sort_order,
179 target_version=item.target_version,
180 introduced_in_version_id=roadmap.version_id,
181 actor_slot=slot,
182 actor_type=actor_type, # type: ignore[arg-type]
183 command="roadmap import",
184 system_actor=True,
185 )
186 if action == "create":
187 created += 1
188 else:
189 updated += 1
190
191 with backend.transaction("immediate") as conn:
192 _upsert_item_tracks(conn, parsed, backend.clock.now_wall())
193
194 for roadmap in parsed:
195 for dependency in roadmap.dependencies:
196 with backend.connect() as conn:
197 exists = conn.execute(
198 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
199 "SELECT 1 FROM roadmap_item_dependencies "
200 "WHERE from_item_id = ? AND to_item_id = ? AND kind = ?",
201 (
202 dependency.from_item_id,
203 dependency.to_item_id,
204 dependency.kind,
205 ),
206 ).fetchone()
207 if exists is not None:
208 continue
209 roadmap_item_add_dependency(
210 registry,
211 from_item_id=dependency.from_item_id,
212 to_item_id=dependency.to_item_id,
213 kind=dependency.kind, # type: ignore[arg-type]
214 in_version_id=roadmap.version_id,
215 actor_slot=slot,
216 actor_type=actor_type, # type: ignore[arg-type]
217 command="roadmap import",
218 system_actor=True,
219 )
220
221 return IngestResult(
222 ok=True,
223 dry_run=False,
224 files=len(files),
225 created=created,
226 updated=updated,
227 skipped=skipped,
228 )
229
230
231def _roadmap_files(path: Path) -> list[Path]:
232 if not path.is_dir():
233 raise FileNotFoundError(path)
234 return sorted(
235 item
236 for item in path.glob("ROADMAP*.md")
237 if item.is_file() and "-v" in item.stem
238 )
239
240
242 conn: sqlite3.Connection,
243 roadmaps: list[ParsedRoadmap],
244 now: str,
245) -> None:
246 latest_by_era: dict[str, str] = {}
247 for roadmap in sorted(roadmaps, key=lambda item: item.version_id):
248 _upsert_era(conn, roadmap, now)
249 _upsert_version(conn, roadmap, "draft")
250 latest_by_era[roadmap.era_id] = roadmap.version_id
251 for track in roadmap.tracks:
252 _upsert_track(conn, track)
253 for phase in roadmap.phases:
254 _upsert_phase(conn, phase, roadmap.version_id, now)
255 for section in roadmap.render_sections:
256 _upsert_render_section(conn, section, roadmap.version_id)
257 for era_id, version_id in latest_by_era.items():
258 conn.execute(
259 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
260 "UPDATE roadmap_versions SET status = 'draft' "
261 "WHERE era_id = ? AND version_id != ? AND status = 'current'",
262 (era_id, version_id),
263 )
264 conn.execute(
265 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
266 "UPDATE roadmap_versions SET status = 'current' WHERE version_id = ?",
267 (version_id,),
268 )
269
270
271def _upsert_era(conn: sqlite3.Connection, roadmap: ParsedRoadmap, now: str) -> None:
272 conn.execute(
273 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
274 "INSERT INTO roadmap_eras("
275 "era_id, era_label, major_bump_reason, started_at, ended_at, archived_under, sort_order"
276 ") VALUES (?, ?, ?, ?, NULL, NULL, ?) "
277 "ON CONFLICT(era_id) DO UPDATE SET "
278 "era_label = excluded.era_label",
279 (
280 roadmap.era_id,
281 f"{roadmap.era_id} roadmap era",
282 None if roadmap.era_id != "v3" else "Migration era",
283 roadmap.created_at or now,
284 _era_sort_order(roadmap.era_id),
285 ),
286 )
287
288
290 conn: sqlite3.Connection,
291 roadmap: ParsedRoadmap,
292 status: str,
293) -> None:
294 conn.execute(
295 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
296 "INSERT INTO roadmap_versions("
297 "version_id, era_id, released_at, status, parent_version_id, "
298 "supersedes_version_id, superseded_by_version_id, last_updated_at, "
299 "source_round_no, bump_kind, notes"
300 ") VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, NULL, ?, ?) "
301 "ON CONFLICT(version_id) DO UPDATE SET "
302 "released_at = excluded.released_at, "
303 "last_updated_at = excluded.last_updated_at, "
304 "bump_kind = excluded.bump_kind, "
305 "notes = excluded.notes",
306 (
307 roadmap.version_id,
308 roadmap.era_id,
309 roadmap.created_at,
310 status,
311 roadmap.last_updated_at,
312 "patch",
313 roadmap.title,
314 ),
315 )
316
317
318def _upsert_track(conn: sqlite3.Connection, track: ParsedTrack) -> None:
319 conn.execute(
320 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
321 "INSERT INTO roadmap_tracks(track_code, label, description, sort_order, active) "
322 "VALUES (?, ?, ?, ?, 1) "
323 "ON CONFLICT(track_code) DO UPDATE SET "
324 "label = excluded.label, "
325 "description = excluded.description, "
326 "sort_order = excluded.sort_order, "
327 "active = 1",
328 (track.track_code, track.label, track.description, track.sort_order),
329 )
330
331
333 conn: sqlite3.Connection,
334 phase: ParsedPhase,
335 version_id: str,
336 now: str,
337) -> None:
338 parent_id = None
339 if phase.parent_phase_code is not None:
340 row = conn.execute(
341 "SELECT phase_id FROM roadmap_phases WHERE phase_code = ?",
342 (phase.parent_phase_code,),
343 ).fetchone()
344 if row is not None:
345 parent_id = int(row["phase_id"])
346 conn.execute(
347 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
348 "INSERT INTO roadmap_phases("
349 "phase_code, label, parent_phase_id, branch_suggestion, commit_prefix, "
350 "target_version, priority_label, parallel_track, full_spec_url, sort_order, "
351 "introduced_in_version_id, retired_in_version_id, created_at, updated_at"
352 ") VALUES (?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?, ?, NULL, ?, ?) "
353 "ON CONFLICT(phase_code) DO UPDATE SET "
354 "label = excluded.label, "
355 "parent_phase_id = COALESCE(roadmap_phases.parent_phase_id, excluded.parent_phase_id), "
356 "priority_label = COALESCE(excluded.priority_label, roadmap_phases.priority_label), "
357 "parallel_track = excluded.parallel_track, "
358 "sort_order = excluded.sort_order, "
359 "updated_at = excluded.updated_at",
360 (
361 phase.phase_code,
362 phase.label,
363 parent_id,
364 phase.target_version,
365 phase.priority_label,
366 1 if phase.parallel_track else 0,
367 phase.sort_order,
368 version_id,
369 now,
370 now,
371 ),
372 )
373
374
376 conn: sqlite3.Connection,
377 section: ParsedRenderSection,
378 version_id: str,
379) -> None:
380 conn.execute(
381 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
382 "DELETE FROM roadmap_render_sections WHERE file_kind = ? AND section_path = ?",
383 (section.file_kind, section.section_path),
384 )
385 conn.execute(
386 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
387 "INSERT INTO roadmap_render_sections("
388 "file_kind, section_path, sort_order, query_kind, query_params_json, "
389 "required_min_version, active"
390 ") VALUES (?, ?, ?, ?, ?, ?, 1)",
391 (
392 section.file_kind,
393 section.section_path,
394 section.sort_order,
395 section.query_kind,
396 section.query_params_json,
397 version_id,
398 ),
399 )
400
401
403 conn: sqlite3.Connection,
404 roadmaps: list[ParsedRoadmap],
405 now: str,
406) -> None:
407 for roadmap in roadmaps:
408 for item in roadmap.items:
409 track_code = _track_code_for_item(item)
410 row = conn.execute(
411 "SELECT track_id FROM roadmap_tracks WHERE track_code = ?",
412 (track_code,),
413 ).fetchone()
414 if row is None:
415 continue
416 conn.execute(
417 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
418 "INSERT OR IGNORE INTO roadmap_item_tracks(item_id, track_id, created_at) "
419 "VALUES (?, ?, ?)",
420 (item.item_id, int(row["track_id"]), now),
421 )
422
423
424def _item_action(conn: sqlite3.Connection, item: ParsedItem) -> str:
425 row = conn.execute(
426 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap SQL projection strings are schema constants, not secrets"
427 "SELECT i.item_id, i.track_kind, p.phase_code, i.parent_item_id, "
428 "i.title, i.prose, i.status, i.priority, i.sort_order, i.target_version "
429 "FROM roadmap_items_v3 i "
430 "LEFT JOIN roadmap_phases p ON p.phase_id = i.phase_id "
431 "WHERE i.item_id = ?",
432 (item.item_id,),
433 ).fetchone()
434 if row is None:
435 return "create"
436 current = {
437 "track_kind": row["track_kind"],
438 "phase_code": row["phase_code"],
439 "parent_item_id": row["parent_item_id"],
440 "title": row["title"],
441 "prose": row["prose"],
442 "status": row["status"],
443 "priority": row["priority"],
444 "sort_order": int(row["sort_order"]),
445 "target_version": row["target_version"],
446 }
447 incoming = {
448 "track_kind": item.track_kind,
449 "phase_code": item.phase_code,
450 "parent_item_id": item.parent_item_id,
451 "title": item.title,
452 "prose": item.prose,
453 "status": item.status,
454 "priority": item.priority,
455 "sort_order": item.sort_order,
456 "target_version": item.target_version,
457 }
458 return "skip" if _canonical_hash(current) == _canonical_hash(incoming) else "update"
459
460
461def _canonical_hash(value: Mapping[str, object]) -> str:
462 import hashlib
463
464 return hashlib.sha256(canonical_json(dict(value)).encode("utf-8")).hexdigest()
465
466
467def _track_code_for_item(item: ParsedItem) -> str:
468 if item.track_kind in {"m_phase", "vision", "git_workflow", "pre_m0_era"}:
469 return item.track_kind
470 if item.item_id.startswith("L"):
471 return "memory"
472 if item.item_id.startswith("U"):
473 return "undo"
474 return "tooling"
475
476
477def _era_sort_order(era_id: str) -> int:
478 text = era_id.removeprefix("v")
479 return int(text) if text.isdigit() else 999
480
481
482def _sqlite_backend(registry: Registry) -> SQLiteBackend:
483 backend = registry.backend
484 if not isinstance(backend, SQLiteBackend):
485 raise TypeError("roadmap ingest requires SQLiteBackend")
486 return backend
None _upsert_static_metadata(sqlite3.Connection conn, list[ParsedRoadmap] roadmaps, str now)
Definition ingest.py:245
None _upsert_phase(sqlite3.Connection conn, ParsedPhase phase, str version_id, str now)
Definition ingest.py:337
None _upsert_render_section(sqlite3.Connection conn, ParsedRenderSection section, str version_id)
Definition ingest.py:379
str _track_code_for_item(ParsedItem item)
Definition ingest.py:467
list[Path] _roadmap_files(Path path)
Definition ingest.py:231
None _upsert_version(sqlite3.Connection conn, ParsedRoadmap roadmap, str status)
Definition ingest.py:293
None _upsert_track(sqlite3.Connection conn, ParsedTrack track)
Definition ingest.py:318
str _canonical_hash(Mapping[str, object] value)
Definition ingest.py:461
int _era_sort_order(str era_id)
Definition ingest.py:477
None _upsert_era(sqlite3.Connection conn, ParsedRoadmap roadmap, str now)
Definition ingest.py:271
str _item_action(sqlite3.Connection conn, ParsedItem item)
Definition ingest.py:424
IngestResult ingest_corpus(Registry registry, str|Path roadmap_dir, *, str|None actor_slot=None, str actor_type="human_operator", bool dry_run=False)
Definition ingest.py:100
None _upsert_item_tracks(sqlite3.Connection conn, list[ParsedRoadmap] roadmaps, str now)
Definition ingest.py:406
SQLiteBackend _sqlite_backend(Registry registry)
Definition ingest.py:482