diary-docs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diary/__init__.py +1 -0
- diary/__main__.py +3 -0
- diary/aimb/__init__.py +48 -0
- diary/aimb/hasher.py +157 -0
- diary/aimb/merge.py +252 -0
- diary/aimb/parser.py +202 -0
- diary/cli.py +999 -0
- diary/git_utils.py +202 -0
- diary/indexer/__init__.py +44 -0
- diary/indexer/database.py +340 -0
- diary/indexer/extractors.py +468 -0
- diary/indexer/gitignore.py +62 -0
- diary/indexer/indexer.py +511 -0
- diary/indexer/reporter.py +137 -0
- diary/indexer/scanner.py +65 -0
- diary/sync/__init__.py +33 -0
- diary/sync/detector.py +405 -0
- diary/sync/engine.py +404 -0
- diary/sync/protocol.py +176 -0
- diary/templates.py +102 -0
- diary_docs-0.1.0.dist-info/METADATA +228 -0
- diary_docs-0.1.0.dist-info/RECORD +26 -0
- diary_docs-0.1.0.dist-info/WHEEL +5 -0
- diary_docs-0.1.0.dist-info/entry_points.txt +2 -0
- diary_docs-0.1.0.dist-info/licenses/LICENSE +21 -0
- diary_docs-0.1.0.dist-info/top_level.txt +1 -0
diary/sync/engine.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Sync orchestrator — integrates git_utils, indexer, detector, merge, and protocol.
|
|
2
|
+
|
|
3
|
+
The :func:`run_sync` function is the main entry point for the diary sync
|
|
4
|
+
pipeline. It ties together branch detection, re-indexing, change detection,
|
|
5
|
+
AIMB conflict resolution, and report generation into a single callable API.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from diary.sync.engine import SyncConfig, run_sync
|
|
10
|
+
|
|
11
|
+
config = SyncConfig(workspace_path=Path("."))
|
|
12
|
+
result = run_sync(config)
|
|
13
|
+
if result.success:
|
|
14
|
+
print(result.report)
|
|
15
|
+
else:
|
|
16
|
+
for err in result.errors:
|
|
17
|
+
print(err)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import subprocess
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from diary.aimb.merge import (
|
|
30
|
+
MergeDecision,
|
|
31
|
+
decide_block_action,
|
|
32
|
+
generate_block_diff,
|
|
33
|
+
)
|
|
34
|
+
from diary.aimb.parser import parse_aimb_blocks
|
|
35
|
+
from diary.git_utils import (
|
|
36
|
+
get_current_branch,
|
|
37
|
+
is_git_repo,
|
|
38
|
+
sanitize_branch_name,
|
|
39
|
+
)
|
|
40
|
+
from diary.indexer.indexer import run_index
|
|
41
|
+
from diary.sync.detector import (
|
|
42
|
+
ChangeType,
|
|
43
|
+
classify_changes,
|
|
44
|
+
detect_code_changes,
|
|
45
|
+
detect_doc_changes,
|
|
46
|
+
)
|
|
47
|
+
from diary.sync.protocol import (
|
|
48
|
+
ChangeEntry,
|
|
49
|
+
ConflictEntry,
|
|
50
|
+
SyncReport,
|
|
51
|
+
SyncStats,
|
|
52
|
+
serialize_report,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Dataclasses
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SyncConfig:
|
|
65
|
+
"""Configuration for a single sync run.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
workspace_path : Path
|
|
70
|
+
Root of the workspace / repository to sync.
|
|
71
|
+
dry_run : bool
|
|
72
|
+
If ``True``, report changes to stdout without modifying any files
|
|
73
|
+
(default ``False``).
|
|
74
|
+
force : bool
|
|
75
|
+
If ``True``, skip safety prompts (default ``False``).
|
|
76
|
+
branch_override : str | None
|
|
77
|
+
Explicit branch name to use instead of auto-detecting from git.
|
|
78
|
+
output_path : str | None
|
|
79
|
+
File path to write the serialised report to instead of stdout.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
workspace_path: Path
|
|
83
|
+
dry_run: bool = False
|
|
84
|
+
force: bool = False
|
|
85
|
+
branch_override: str | None = None
|
|
86
|
+
output_path: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class SyncResult:
|
|
91
|
+
"""Result produced by a single call to :func:`run_sync`.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
success : bool
|
|
96
|
+
``True`` when the sync completed without errors.
|
|
97
|
+
report : SyncReport | None
|
|
98
|
+
The generated sync report, or ``None`` if pre-flight checks failed.
|
|
99
|
+
errors : list[str]
|
|
100
|
+
Human-readable error / warning messages collected during the run.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
success: bool
|
|
104
|
+
report: SyncReport | None = None
|
|
105
|
+
errors: list[str] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Internal helpers
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _git_show(workspace_path: Path, file_path: str) -> str:
|
|
114
|
+
"""Return the committed content of *file_path* at HEAD.
|
|
115
|
+
|
|
116
|
+
Returns an empty string when the file is not tracked or on error.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
["git", "show", f"HEAD:{file_path}"],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
cwd=str(workspace_path),
|
|
124
|
+
)
|
|
125
|
+
if result.returncode == 0:
|
|
126
|
+
return result.stdout
|
|
127
|
+
except (FileNotFoundError, OSError):
|
|
128
|
+
pass
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_branch(config: SyncConfig) -> tuple[str, list[str]]:
|
|
133
|
+
"""Resolve the effective branch name.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
tuple[str, list[str]]
|
|
138
|
+
``(branch_name, warnings)``
|
|
139
|
+
"""
|
|
140
|
+
warnings: list[str] = []
|
|
141
|
+
|
|
142
|
+
if config.branch_override:
|
|
143
|
+
return config.branch_override, warnings
|
|
144
|
+
|
|
145
|
+
branch = get_current_branch(config.workspace_path)
|
|
146
|
+
if not branch:
|
|
147
|
+
warnings.append("Could not detect current branch — using 'unknown'")
|
|
148
|
+
return "unknown", warnings
|
|
149
|
+
|
|
150
|
+
if branch == "HEAD":
|
|
151
|
+
warnings.append(
|
|
152
|
+
"Detached HEAD state detected — using 'detached' for index isolation. "
|
|
153
|
+
"The knowledge database will be stored as knowledge-detached.db"
|
|
154
|
+
)
|
|
155
|
+
return "detached", warnings
|
|
156
|
+
|
|
157
|
+
return branch, warnings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_change_entries(classification: dict) -> list[ChangeEntry]:
|
|
161
|
+
"""Convert classified :class:`~diary.sync.detector.DetectedChange` objects
|
|
162
|
+
into protocol :class:`~diary.sync.protocol.ChangeEntry` instances."""
|
|
163
|
+
entries: list[ChangeEntry] = []
|
|
164
|
+
for _type_name, changes in classification.get("by_type", {}).items():
|
|
165
|
+
for change in changes:
|
|
166
|
+
entries.append(
|
|
167
|
+
ChangeEntry(
|
|
168
|
+
file_path=change.file_path,
|
|
169
|
+
change_type=change.change_type.value,
|
|
170
|
+
affected_docs=list(change.affected_aimb_blocks),
|
|
171
|
+
confidence=change.confidence,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return entries
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_conflicts(
|
|
178
|
+
workspace_path: Path,
|
|
179
|
+
doc_changes: list,
|
|
180
|
+
) -> tuple[list[ConflictEntry], list[str]]:
|
|
181
|
+
"""Detect merge conflicts for changed AIMB blocks.
|
|
182
|
+
|
|
183
|
+
For each documented change classified as ``AIMB_BLOCK_CHANGED``, compare
|
|
184
|
+
the current block hash (in the working tree) against the committed hash
|
|
185
|
+
(from git HEAD). If they differ, the block was manually edited since the
|
|
186
|
+
last AI write, and we flag it as a conflict.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
tuple[list[ConflictEntry], list[str]]
|
|
191
|
+
``(conflict_entries, errors)``
|
|
192
|
+
"""
|
|
193
|
+
conflicts: list[ConflictEntry] = []
|
|
194
|
+
errors: list[str] = []
|
|
195
|
+
|
|
196
|
+
for change in doc_changes:
|
|
197
|
+
if change.change_type != ChangeType.AIMB_BLOCK_CHANGED:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
file_path = workspace_path / change.file_path
|
|
201
|
+
if not file_path.is_file():
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Read current working-tree content
|
|
205
|
+
try:
|
|
206
|
+
current_content = file_path.read_text(encoding="utf-8")
|
|
207
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
208
|
+
errors.append(f"Cannot read {change.file_path}: {exc}")
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Get committed version from git HEAD
|
|
212
|
+
committed_content = _git_show(workspace_path, change.file_path)
|
|
213
|
+
if not committed_content:
|
|
214
|
+
# File is new (not yet committed) — no conflict possible
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Parse AIMB blocks from both versions
|
|
218
|
+
committed_blocks = parse_aimb_blocks(committed_content)
|
|
219
|
+
committed_hashes = {b.id: b.hash for b in committed_blocks}
|
|
220
|
+
|
|
221
|
+
current_blocks = parse_aimb_blocks(current_content)
|
|
222
|
+
current_by_id = {b.id: b for b in current_blocks}
|
|
223
|
+
|
|
224
|
+
for block_id in change.affected_aimb_blocks:
|
|
225
|
+
stored_hash = committed_hashes.get(block_id)
|
|
226
|
+
if stored_hash is None:
|
|
227
|
+
# Block didn't exist in committed version — can't conflict
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
block = current_by_id.get(block_id)
|
|
231
|
+
if block is None:
|
|
232
|
+
# Block was removed from the working tree — skip
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
decision = decide_block_action(block, stored_hash, current_content)
|
|
236
|
+
if decision == MergeDecision.REPLACE:
|
|
237
|
+
# Hashes match — no manual edit, safe to overwrite
|
|
238
|
+
continue
|
|
239
|
+
if decision == MergeDecision.SKIP:
|
|
240
|
+
# Block marked for removal — skip
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# FLAG_CONFLICT — build a diff preview between the two versions
|
|
244
|
+
committed_block = None
|
|
245
|
+
for cb in committed_blocks:
|
|
246
|
+
if cb.id == block_id:
|
|
247
|
+
committed_block = cb
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
diff_preview = ""
|
|
251
|
+
if committed_block is not None:
|
|
252
|
+
diff_preview = generate_block_diff(committed_block.content, block.content)
|
|
253
|
+
|
|
254
|
+
conflicts.append(
|
|
255
|
+
ConflictEntry(
|
|
256
|
+
file_path=change.file_path,
|
|
257
|
+
block_id=block_id,
|
|
258
|
+
old_hash=stored_hash,
|
|
259
|
+
current_hash=block.hash,
|
|
260
|
+
diff_preview=diff_preview,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return conflicts, errors
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Public API
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_sync(config: SyncConfig) -> SyncResult:
|
|
273
|
+
"""Execute a full sync cycle.
|
|
274
|
+
|
|
275
|
+
Pipeline steps
|
|
276
|
+
--------------
|
|
277
|
+
1. **Pre-flight checks** — verify the workspace is a git repository.
|
|
278
|
+
2. **Branch detection** — resolve the current branch (detached HEAD is
|
|
279
|
+
handled gracefully and mapped to ``"detached"``).
|
|
280
|
+
3. **Re-indexing** — rebuild the per-branch knowledge index via
|
|
281
|
+
:func:`~diary.indexer.indexer.run_index`.
|
|
282
|
+
4. **Change detection** — scan for code changes and documentation changes
|
|
283
|
+
via :func:`~diary.sync.detector.detect_code_changes` and
|
|
284
|
+
:func:`~diary.sync.detector.detect_doc_changes`.
|
|
285
|
+
5. **Classification** — group and summarise all detected changes via
|
|
286
|
+
:func:`~diary.sync.detector.classify_changes`.
|
|
287
|
+
6. **AIMB merge decisions** — for each changed documentation block, call
|
|
288
|
+
:func:`~diary.aimb.merge.decide_block_action` to detect conflicts.
|
|
289
|
+
7. **Build report** — construct a :class:`~diary.sync.protocol.SyncReport`
|
|
290
|
+
with change entries, conflicts, and aggregate statistics.
|
|
291
|
+
8. **Output** — serialise the report to JSON and write it to stdout (or
|
|
292
|
+
to a file if ``config.output_path`` is set).
|
|
293
|
+
If ``config.dry_run`` is ``True``, no files are modified.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
config : SyncConfig
|
|
298
|
+
Sync configuration.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
SyncResult
|
|
303
|
+
"""
|
|
304
|
+
errors: list[str] = []
|
|
305
|
+
start_time = time.monotonic()
|
|
306
|
+
|
|
307
|
+
# ── 1. Pre-flight checks ────────────────────────────────
|
|
308
|
+
if not is_git_repo(config.workspace_path):
|
|
309
|
+
msg = (
|
|
310
|
+
f"Not a git repository: {config.workspace_path}\n"
|
|
311
|
+
"Run ``git init`` to initialise a repository, or set "
|
|
312
|
+
"``workspace_path`` to a valid git workspace."
|
|
313
|
+
)
|
|
314
|
+
errors.append(msg)
|
|
315
|
+
logger.error(msg)
|
|
316
|
+
return SyncResult(success=False, errors=errors)
|
|
317
|
+
|
|
318
|
+
# ── 2. Branch detection ─────────────────────────────────
|
|
319
|
+
branch, branch_warnings = _resolve_branch(config)
|
|
320
|
+
errors.extend(branch_warnings)
|
|
321
|
+
|
|
322
|
+
for w in branch_warnings:
|
|
323
|
+
logger.warning(w)
|
|
324
|
+
|
|
325
|
+
sanitized = sanitize_branch_name(branch)
|
|
326
|
+
|
|
327
|
+
# ── 3. Re-indexing ──────────────────────────────────────
|
|
328
|
+
logger.info("Re-indexing workspace for branch '%s'", branch)
|
|
329
|
+
try:
|
|
330
|
+
db = run_index(config.workspace_path, branch_name=sanitized)
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
msg = f"Indexing failed: {exc}"
|
|
333
|
+
errors.append(msg)
|
|
334
|
+
logger.error(msg)
|
|
335
|
+
return SyncResult(success=False, errors=errors)
|
|
336
|
+
|
|
337
|
+
# ── 4. Change detection ─────────────────────────────────
|
|
338
|
+
logger.info("Detecting code changes")
|
|
339
|
+
code_changes = detect_code_changes(config.workspace_path, db)
|
|
340
|
+
|
|
341
|
+
logger.info("Detecting documentation changes")
|
|
342
|
+
doc_changes = detect_doc_changes(config.workspace_path)
|
|
343
|
+
|
|
344
|
+
all_changes = code_changes + doc_changes
|
|
345
|
+
|
|
346
|
+
# ── 5. Classification ───────────────────────────────────
|
|
347
|
+
classification = classify_changes(all_changes)
|
|
348
|
+
logger.info("Sync classification: %s", classification.get("summary", "unknown"))
|
|
349
|
+
|
|
350
|
+
# ── 6. AIMB merge decisions ─────────────────────────────
|
|
351
|
+
conflict_entries, conflict_errors = _resolve_conflicts(
|
|
352
|
+
config.workspace_path,
|
|
353
|
+
doc_changes,
|
|
354
|
+
)
|
|
355
|
+
errors.extend(conflict_errors)
|
|
356
|
+
|
|
357
|
+
# ── 7. Build report ─────────────────────────────────────
|
|
358
|
+
row = db.conn.execute("SELECT COUNT(*) FROM files").fetchone()
|
|
359
|
+
files_scanned = row[0] if row else 0
|
|
360
|
+
|
|
361
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
362
|
+
|
|
363
|
+
change_entries = _build_change_entries(classification)
|
|
364
|
+
|
|
365
|
+
# Count unique doc files with detected changes
|
|
366
|
+
doc_paths: set[str] = set()
|
|
367
|
+
for change in doc_changes:
|
|
368
|
+
doc_paths.add(change.file_path)
|
|
369
|
+
docs_updated = len(doc_paths)
|
|
370
|
+
|
|
371
|
+
report = SyncReport(
|
|
372
|
+
branch=branch,
|
|
373
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
374
|
+
changes=change_entries,
|
|
375
|
+
conflicts=conflict_entries,
|
|
376
|
+
stats=SyncStats(
|
|
377
|
+
files_scanned=files_scanned,
|
|
378
|
+
docs_updated=docs_updated,
|
|
379
|
+
conflicts=len(conflict_entries),
|
|
380
|
+
duration_ms=duration_ms,
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# ── 8. Output ───────────────────────────────────────────
|
|
385
|
+
serialized = serialize_report(report)
|
|
386
|
+
|
|
387
|
+
if config.output_path:
|
|
388
|
+
output = Path(config.output_path)
|
|
389
|
+
try:
|
|
390
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
output.write_text(serialized, encoding="utf-8")
|
|
392
|
+
logger.info("Report written to %s", output)
|
|
393
|
+
except OSError as exc:
|
|
394
|
+
msg = f"Cannot write report to {output}: {exc}"
|
|
395
|
+
errors.append(msg)
|
|
396
|
+
logger.error(msg)
|
|
397
|
+
else:
|
|
398
|
+
print(serialized)
|
|
399
|
+
|
|
400
|
+
# Clean up database handle
|
|
401
|
+
db.close()
|
|
402
|
+
|
|
403
|
+
success = len(errors) == 0
|
|
404
|
+
return SyncResult(success=success, report=report, errors=errors)
|
diary/sync/protocol.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""JSON protocol schema for AI agent communication during diary sync.
|
|
2
|
+
|
|
3
|
+
This module defines the data structures and serialization helpers
|
|
4
|
+
that format sync results into JSON consumed by the ``/diary-sync``
|
|
5
|
+
AI agent command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, get_type_hints
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Data classes ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ChangeEntry:
|
|
19
|
+
"""A single code change detected during the sync scan."""
|
|
20
|
+
|
|
21
|
+
file_path: str
|
|
22
|
+
change_type: str # e.g. "modified", "added", "deleted"
|
|
23
|
+
affected_docs: list[str] = field(default_factory=list)
|
|
24
|
+
confidence: float = 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConflictEntry:
|
|
29
|
+
"""A documentation block that was edited on both sides since last sync."""
|
|
30
|
+
|
|
31
|
+
file_path: str
|
|
32
|
+
block_id: str
|
|
33
|
+
old_hash: str
|
|
34
|
+
current_hash: str
|
|
35
|
+
diff_preview: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SyncStats:
|
|
40
|
+
"""Aggregate counters for a single sync run."""
|
|
41
|
+
|
|
42
|
+
files_scanned: int = 0
|
|
43
|
+
docs_updated: int = 0
|
|
44
|
+
conflicts: int = 0
|
|
45
|
+
duration_ms: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SyncReport:
|
|
50
|
+
"""Top-level report produced after a sync scan completes."""
|
|
51
|
+
|
|
52
|
+
branch: str
|
|
53
|
+
timestamp: str # ISO-8601
|
|
54
|
+
changes: list[ChangeEntry] = field(default_factory=list)
|
|
55
|
+
conflicts: list[ConflictEntry] = field(default_factory=list)
|
|
56
|
+
stats: SyncStats = field(default_factory=SyncStats)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Serialization ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _from_dict(cls: type[Any], data: dict[str, Any]) -> Any:
|
|
63
|
+
"""Recursively build a dataclass (and nested dataclasses) from *data*."""
|
|
64
|
+
if not dataclasses.is_dataclass(cls):
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
field_types = {f.name: f.type for f in dataclasses.fields(cls)}
|
|
68
|
+
kwargs: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
for fname, ftype in field_types.items():
|
|
71
|
+
if fname not in data:
|
|
72
|
+
continue
|
|
73
|
+
raw = data[fname]
|
|
74
|
+
|
|
75
|
+
# list[X] – try to unwrap the origin
|
|
76
|
+
origin = getattr(ftype, "__origin__", None)
|
|
77
|
+
if origin is list:
|
|
78
|
+
args = getattr(ftype, "__args__", (Any,))
|
|
79
|
+
elem_type = args[0] if args else Any
|
|
80
|
+
if dataclasses.is_dataclass(elem_type):
|
|
81
|
+
kwargs[fname] = [_from_dict(elem_type, item) for item in raw]
|
|
82
|
+
elif isinstance(raw, list):
|
|
83
|
+
kwargs[fname] = raw
|
|
84
|
+
else:
|
|
85
|
+
kwargs[fname] = raw
|
|
86
|
+
elif dataclasses.is_dataclass(ftype):
|
|
87
|
+
kwargs[fname] = _from_dict(ftype, raw)
|
|
88
|
+
else:
|
|
89
|
+
kwargs[fname] = raw
|
|
90
|
+
|
|
91
|
+
return cls(**kwargs)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def serialize_report(report: SyncReport) -> str:
|
|
95
|
+
"""Serialize *report* to a pretty-printed JSON string."""
|
|
96
|
+
return json.dumps(dataclasses.asdict(report), indent=2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_report(json_str: str) -> SyncReport:
|
|
100
|
+
"""Deserialize a JSON string back into a ``SyncReport`` instance."""
|
|
101
|
+
raw: dict[str, Any] = json.loads(json_str)
|
|
102
|
+
return _from_dict(SyncReport, raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── AI prompt generation ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_ai_update_prompt(report: SyncReport) -> str:
|
|
109
|
+
"""Generate a natural-language instruction string for an AI agent.
|
|
110
|
+
|
|
111
|
+
The prompt describes every change and conflict recorded in *report* so
|
|
112
|
+
that an AI agent can understand which documentation blocks need updating
|
|
113
|
+
and which ones require manual conflict resolution.
|
|
114
|
+
"""
|
|
115
|
+
lines: list[str] = []
|
|
116
|
+
lines.append("## DIARY Sync Update Instructions")
|
|
117
|
+
lines.append("")
|
|
118
|
+
lines.append(f"**Branch:** {report.branch}")
|
|
119
|
+
lines.append(f"**Timestamp:** {report.timestamp}")
|
|
120
|
+
lines.append(f"**Files scanned:** {report.stats.files_scanned}")
|
|
121
|
+
lines.append(f"**Docs updated:** {report.stats.docs_updated}")
|
|
122
|
+
lines.append(f"**Duration:** {report.stats.duration_ms} ms")
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
if report.changes:
|
|
126
|
+
lines.append("### Documentation Updates Required")
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines.append(
|
|
129
|
+
"The following code changes need corresponding documentation updates:"
|
|
130
|
+
)
|
|
131
|
+
lines.append("")
|
|
132
|
+
for ch in report.changes:
|
|
133
|
+
lines.append(f"- **{ch.file_path}** ({ch.change_type})")
|
|
134
|
+
if ch.affected_docs:
|
|
135
|
+
lines.append(f" - Affected docs: {', '.join(ch.affected_docs)}")
|
|
136
|
+
lines.append(f" - Confidence: {ch.confidence:.0%}")
|
|
137
|
+
lines.append("")
|
|
138
|
+
else:
|
|
139
|
+
lines.append("*No documentation updates required.*")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
if report.conflicts:
|
|
143
|
+
lines.append("### Conflicts Detected (Manual Resolution Required)")
|
|
144
|
+
lines.append("")
|
|
145
|
+
lines.append(
|
|
146
|
+
"The following documentation blocks have been edited on both "
|
|
147
|
+
"sides since the last sync and need manual review:"
|
|
148
|
+
)
|
|
149
|
+
lines.append("")
|
|
150
|
+
for cf in report.conflicts:
|
|
151
|
+
lines.append(f"- **{cf.file_path}** — block `{cf.block_id}`")
|
|
152
|
+
lines.append(f" - Old hash: `{cf.old_hash}`")
|
|
153
|
+
lines.append(f" - Current hash: `{cf.current_hash}`")
|
|
154
|
+
lines.append(f" - Diff preview: {cf.diff_preview}")
|
|
155
|
+
lines.append("")
|
|
156
|
+
else:
|
|
157
|
+
lines.append("*No conflicts detected.*")
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
lines.append("### Action Items")
|
|
161
|
+
lines.append("")
|
|
162
|
+
if report.changes:
|
|
163
|
+
lines.append(
|
|
164
|
+
"1. For each change listed above, update the corresponding "
|
|
165
|
+
"documentation block to reflect the new code."
|
|
166
|
+
)
|
|
167
|
+
if report.conflicts:
|
|
168
|
+
lines.append(
|
|
169
|
+
"2. For each conflict, manually review the diff preview and "
|
|
170
|
+
"decide which version to keep (or merge both)."
|
|
171
|
+
)
|
|
172
|
+
if not report.changes and not report.conflicts:
|
|
173
|
+
lines.append("No action required — everything is up to date.")
|
|
174
|
+
lines.append("")
|
|
175
|
+
|
|
176
|
+
return "\n".join(lines)
|
diary/templates.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def aimb_wrap_body(content: str, filename: str) -> str:
|
|
6
|
+
"""Wrap body content (after frontmatter) in an AI-managed block.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
content: Full file content including frontmatter.
|
|
10
|
+
filename: Filename (e.g. ``index.md``) — the stem becomes the AIMB id.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Content with an ``<!-- ai-managed … -->`` wrapper around everything
|
|
14
|
+
after the first YAML frontmatter block.
|
|
15
|
+
"""
|
|
16
|
+
today = date.today().isoformat()
|
|
17
|
+
aimb_id = Path(filename).stem
|
|
18
|
+
opening = f'<!-- ai-managed id="{aimb_id}" hash="init" updated="{today}" -->'
|
|
19
|
+
closing = "<!-- /ai-managed -->"
|
|
20
|
+
|
|
21
|
+
if content.startswith("---"):
|
|
22
|
+
parts = content.split("---", 2)
|
|
23
|
+
if len(parts) == 3:
|
|
24
|
+
_, front, body = parts
|
|
25
|
+
body = body.strip("\n")
|
|
26
|
+
return f"---{front}---\n\n{opening}\n{body}\n\n{closing}\n"
|
|
27
|
+
return f"{opening}\n{content}\n\n{closing}\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MKDOCS_TEMPLATE = """\
|
|
31
|
+
site_name: {project_name}
|
|
32
|
+
site_description: Technical documentation and knowledge base for {project_name}
|
|
33
|
+
|
|
34
|
+
nav:
|
|
35
|
+
- Pendahuluan:
|
|
36
|
+
- Beranda: index.md
|
|
37
|
+
- Tujuan: objectives.md
|
|
38
|
+
- Panduan Umum:
|
|
39
|
+
- Panduan Menulis Docs: structure/index.md
|
|
40
|
+
- Technical Documentation:
|
|
41
|
+
- Overview: techdocs/index.md
|
|
42
|
+
- Arsitektur: techdocs/architecture.md
|
|
43
|
+
- API Contracts: techdocs/api-contracts.md
|
|
44
|
+
- Deployment Flow: techdocs/deployment.md
|
|
45
|
+
- ADR Log: techdocs/adr.md
|
|
46
|
+
- Development Guide: techdocs/development-guide.md
|
|
47
|
+
- Product Documentation:
|
|
48
|
+
- Overview: product/index.md
|
|
49
|
+
- User Guide: product/user-guide.md
|
|
50
|
+
- Modules: product/modules.md
|
|
51
|
+
- Ops & Governance:
|
|
52
|
+
- Overview: ops-governance/index.md
|
|
53
|
+
- Runbooks: ops-governance/runbooks.md
|
|
54
|
+
- Security: ops-governance/security.md
|
|
55
|
+
- Knowledge Base:
|
|
56
|
+
- Overview: knowledge-base/index.md
|
|
57
|
+
- FAQ: knowledge-base/faq.md
|
|
58
|
+
- Troubleshooting: knowledge-base/troubleshooting.md
|
|
59
|
+
|
|
60
|
+
plugins:
|
|
61
|
+
- techdocs-core
|
|
62
|
+
|
|
63
|
+
markdown_extensions:
|
|
64
|
+
- admonition
|
|
65
|
+
- pymdownx.details
|
|
66
|
+
- pymdownx.superfences:
|
|
67
|
+
custom_fences:
|
|
68
|
+
- name: mermaid
|
|
69
|
+
class: mermaid
|
|
70
|
+
format: !!python/name:pymdownx.superfences.fence_code_format
|
|
71
|
+
- pymdownx.tabbed:
|
|
72
|
+
alternate_style: true
|
|
73
|
+
- pymdownx.highlight:
|
|
74
|
+
anchor_linenums: true
|
|
75
|
+
- pymdownx.inlinehilite
|
|
76
|
+
- pymdownx.snippets
|
|
77
|
+
- tables
|
|
78
|
+
- attr_list
|
|
79
|
+
- md_in_html
|
|
80
|
+
- toc:
|
|
81
|
+
permalink: true
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CATALOG_TEMPLATE = """\
|
|
86
|
+
apiVersion: backstage.io/v1alpha1
|
|
87
|
+
kind: Component
|
|
88
|
+
metadata:
|
|
89
|
+
name: {project_name}
|
|
90
|
+
title: {project_title}
|
|
91
|
+
description: Documentation site for {project_name}
|
|
92
|
+
annotations:
|
|
93
|
+
backstage.io/techdocs-ref: dir:.
|
|
94
|
+
gitlab.com/project-id: '0'
|
|
95
|
+
gitlab.com/instance: gitlab.example.com
|
|
96
|
+
gitlab.com/project-slug: group/{project_name}
|
|
97
|
+
tags: []
|
|
98
|
+
spec:
|
|
99
|
+
type: documentation
|
|
100
|
+
lifecycle: production
|
|
101
|
+
owner: unknown
|
|
102
|
+
"""
|