ligarb 0.3.0 → 0.5.0
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.
- checksums.yaml +4 -4
- data/assets/review.css +665 -0
- data/assets/review.js +681 -0
- data/assets/serve.js +76 -0
- data/assets/style.css +95 -0
- data/lib/ligarb/asset_manager.rb +1 -1
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +45 -2
- data/lib/ligarb/claude_runner.rb +185 -0
- data/lib/ligarb/cli.rb +169 -3
- data/lib/ligarb/config.rb +39 -1
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +112 -0
- data/lib/ligarb/server.rb +1091 -0
- data/lib/ligarb/template.rb +4 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +226 -0
- data/templates/book.html.erb +141 -13
- metadata +37 -1
data/lib/ligarb/cli.rb
CHANGED
|
@@ -17,6 +17,27 @@ module Ligarb
|
|
|
17
17
|
Builder.new(config_path).build
|
|
18
18
|
when "init"
|
|
19
19
|
Initializer.new(args.first).run
|
|
20
|
+
when "serve"
|
|
21
|
+
config_paths = args.reject { |a| a.start_with?("--") }
|
|
22
|
+
config_paths = ["book.yml"] if config_paths.empty?
|
|
23
|
+
port_idx = args.index("--port")
|
|
24
|
+
port = port_idx ? args[port_idx + 1].to_i : 3000
|
|
25
|
+
require_relative "server"
|
|
26
|
+
Server.new(config_paths, port: port).start
|
|
27
|
+
when "write"
|
|
28
|
+
require_relative "writer"
|
|
29
|
+
begin
|
|
30
|
+
if args.delete("--init")
|
|
31
|
+
Writer.init_brief(args.first)
|
|
32
|
+
else
|
|
33
|
+
brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
|
|
34
|
+
no_build = args.include?("--no-build")
|
|
35
|
+
Writer.new(brief_path, no_build: no_build).run
|
|
36
|
+
end
|
|
37
|
+
rescue Writer::WriterError => e
|
|
38
|
+
$stderr.puts "Error: #{e.message}"
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
20
41
|
when "--help", "-h", nil
|
|
21
42
|
print_usage
|
|
22
43
|
when "help"
|
|
@@ -37,6 +58,9 @@ module Ligarb
|
|
|
37
58
|
Usage:
|
|
38
59
|
ligarb init [DIRECTORY] Create a new book project
|
|
39
60
|
ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
|
|
61
|
+
ligarb serve [CONFIG] Serve the book with live reload and review UI
|
|
62
|
+
ligarb write [BRIEF] Generate a book with AI from brief.yml
|
|
63
|
+
ligarb write --init [DIR] Create DIR/brief.yml template
|
|
40
64
|
ligarb help Show detailed specification (for AI integration)
|
|
41
65
|
ligarb version Show version number
|
|
42
66
|
|
|
@@ -53,6 +77,8 @@ module Ligarb
|
|
|
53
77
|
chapter_numbers (optional) Show chapter/section numbers (default: true)
|
|
54
78
|
style (optional) Custom CSS file path (default: none)
|
|
55
79
|
repository (optional) GitHub repository URL for "Edit on GitHub" links
|
|
80
|
+
ai_generated (optional) Mark as AI-generated (badge + meta tags, default: false)
|
|
81
|
+
footer (optional) Custom text at bottom of each chapter
|
|
56
82
|
|
|
57
83
|
Example:
|
|
58
84
|
ligarb build
|
|
@@ -60,8 +86,8 @@ module Ligarb
|
|
|
60
86
|
USAGE
|
|
61
87
|
end
|
|
62
88
|
|
|
63
|
-
def
|
|
64
|
-
|
|
89
|
+
def spec_text
|
|
90
|
+
<<~SPEC
|
|
65
91
|
ligarb - Generate a single-page HTML book from Markdown files
|
|
66
92
|
|
|
67
93
|
Version: #{VERSION}
|
|
@@ -95,6 +121,31 @@ module Ligarb
|
|
|
95
121
|
ligarb build [CONFIG] Build the HTML book.
|
|
96
122
|
CONFIG defaults to 'book.yml' in the current directory.
|
|
97
123
|
|
|
124
|
+
ligarb serve [CONFIG...]
|
|
125
|
+
Start a local web server with live reload and review UI.
|
|
126
|
+
CONFIG defaults to 'book.yml' in the current directory.
|
|
127
|
+
Multiple CONFIG paths can be given to serve multiple books.
|
|
128
|
+
Options:
|
|
129
|
+
--port PORT Server port (default: 3000)
|
|
130
|
+
Single book mode (1 CONFIG):
|
|
131
|
+
- Serves the built HTML book at http://localhost:PORT
|
|
132
|
+
Multi-book mode (2+ CONFIGs):
|
|
133
|
+
- Top page (/) shows a book index with links
|
|
134
|
+
- Each book is served at /<directory-name>/
|
|
135
|
+
- "Write a new book" button on the index page to generate
|
|
136
|
+
a new book via AI (posts a brief, runs Writer in background)
|
|
137
|
+
- Example: ligarb serve */book.yml
|
|
138
|
+
Features:
|
|
139
|
+
- Injects a reload button that pulses when build output changes
|
|
140
|
+
- Injects a review UI for commenting on book text
|
|
141
|
+
- Review comments are saved to .ligarb/reviews/*.json
|
|
142
|
+
(in each book's directory)
|
|
143
|
+
- If 'claude' CLI is installed, comments are sent to Claude
|
|
144
|
+
for review suggestions, and approved changes are applied
|
|
145
|
+
to the source Markdown files and the book is rebuilt
|
|
146
|
+
- Review patches can span multiple chapters and the
|
|
147
|
+
bibliography file (Claude reads book.yml to find all files)
|
|
148
|
+
|
|
98
149
|
ligarb help Show this detailed specification.
|
|
99
150
|
|
|
100
151
|
ligarb --help Show short usage summary.
|
|
@@ -117,6 +168,14 @@ module Ligarb
|
|
|
117
168
|
When set, each chapter shows a "View on GitHub" link.
|
|
118
169
|
The link points to {repository}/blob/HEAD/{path-from-git-root}.
|
|
119
170
|
The chapter path is resolved relative to the Git repository root.
|
|
171
|
+
ai_generated: (optional) Mark the book as AI-generated content. Default: false.
|
|
172
|
+
When true: adds an "AI Generated" badge in the sidebar header,
|
|
173
|
+
adds a default disclaimer footer to each chapter, and adds
|
|
174
|
+
noindex/noai meta tags to prevent search indexing and AI training.
|
|
175
|
+
The footer text can be overridden with the 'footer' field.
|
|
176
|
+
footer: (optional) Custom text displayed at the bottom of each chapter.
|
|
177
|
+
Overrides the default ai_generated disclaimer if both are set.
|
|
178
|
+
Useful for copyright notices, disclaimers, or other per-chapter text.
|
|
120
179
|
chapters: (required) Book structure. An array that can contain:
|
|
121
180
|
- A cover: a centered title/landing page
|
|
122
181
|
- A string: a chapter Markdown file path (relative to book.yml)
|
|
@@ -262,6 +321,17 @@ module Ligarb
|
|
|
262
321
|
E = mc^2
|
|
263
322
|
```
|
|
264
323
|
|
|
324
|
+
Inline math uses $...$ syntax within text:
|
|
325
|
+
|
|
326
|
+
The equation $E = mc^2$ is well-known.
|
|
327
|
+
|
|
328
|
+
Rules for inline math:
|
|
329
|
+
- $$ is not matched (use ```math for display math)
|
|
330
|
+
- $ followed by a space is not matched (e.g. $10)
|
|
331
|
+
- $ preceded by a space is not matched
|
|
332
|
+
- Content inside <code> and <pre> is not affected
|
|
333
|
+
- The content is rendered with KaTeX (displayMode: false)
|
|
334
|
+
|
|
265
335
|
== Images ==
|
|
266
336
|
|
|
267
337
|
Place image files in the 'images/' directory next to book.yml.
|
|
@@ -311,6 +381,67 @@ module Ligarb
|
|
|
311
381
|
|
|
312
382
|
Clicking an index entry navigates to the exact location in the chapter.
|
|
313
383
|
|
|
384
|
+
== Bibliography ==
|
|
385
|
+
|
|
386
|
+
Cite references in the text using Markdown link syntax with #cite:
|
|
387
|
+
|
|
388
|
+
[Ruby](#cite:matz1995) Cite by key; rendered as Ruby[Matsumoto, 1995]
|
|
389
|
+
|
|
390
|
+
Define a bibliography data file in book.yml:
|
|
391
|
+
|
|
392
|
+
bibliography: references.yml # YAML format
|
|
393
|
+
bibliography: references.bib # BibTeX format
|
|
394
|
+
|
|
395
|
+
The format is auto-detected by file extension (.bib = BibTeX, otherwise YAML).
|
|
396
|
+
|
|
397
|
+
YAML format maps keys to reference data:
|
|
398
|
+
|
|
399
|
+
matz1995:
|
|
400
|
+
author: "Yukihiro Matsumoto"
|
|
401
|
+
title: "The Ruby Programming Language"
|
|
402
|
+
year: 1995
|
|
403
|
+
url: "https://www.ruby-lang.org"
|
|
404
|
+
publisher: "O'Reilly"
|
|
405
|
+
doi: "10.1234/example"
|
|
406
|
+
|
|
407
|
+
BibTeX format (.bib) is also supported:
|
|
408
|
+
|
|
409
|
+
@book{matz1995,
|
|
410
|
+
author = {Yukihiro Matsumoto},
|
|
411
|
+
title = {The Ruby Programming Language},
|
|
412
|
+
year = {1995},
|
|
413
|
+
publisher = {O'Reilly},
|
|
414
|
+
url = {https://www.ruby-lang.org}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
BibTeX notes:
|
|
418
|
+
- Entry types (@book, @article, etc.) are preserved for formatting
|
|
419
|
+
- Field values can use {braces} or "quotes"
|
|
420
|
+
- Nested braces are supported one level deep ({The {Ruby} Language})
|
|
421
|
+
- Lines starting with % are comments
|
|
422
|
+
|
|
423
|
+
Supported fields (YAML and BibTeX):
|
|
424
|
+
author, title, year, url, publisher, journal, booktitle, volume,
|
|
425
|
+
number, pages, edition, doi, editor, note.
|
|
426
|
+
|
|
427
|
+
The bibliography section formats entries by type:
|
|
428
|
+
- book: Author. Title. Edition. Publisher, Year.
|
|
429
|
+
- article: Author. "Title". Journal, Volume(Number), Pages, Year.
|
|
430
|
+
- inproceedings: Author. "Title". In Booktitle, Pages, Year.
|
|
431
|
+
- other/YAML: Author. Title. Publisher/Journal, Volume, Pages, Year.
|
|
432
|
+
|
|
433
|
+
If url is present, the title becomes a link. If doi is present, a DOI link
|
|
434
|
+
is appended.
|
|
435
|
+
|
|
436
|
+
The citation is rendered as a superscript [author, year] link that navigates
|
|
437
|
+
to the "Bibliography" section at the end of the book. Hovering the link shows
|
|
438
|
+
the full reference. The bibliography section lists all cited entries sorted by
|
|
439
|
+
author and year.
|
|
440
|
+
|
|
441
|
+
A warning is printed and the citation is rendered as [key?] (highlighted in
|
|
442
|
+
red) if a cite key is not found in the bibliography file.
|
|
443
|
+
If no bibliography file is configured, cite markers are left as-is.
|
|
444
|
+
|
|
314
445
|
== Custom CSS ==
|
|
315
446
|
|
|
316
447
|
Add a 'style' field to book.yml to inject custom CSS:
|
|
@@ -405,8 +536,43 @@ module Ligarb
|
|
|
405
536
|
|
|
406
537
|
Each chapter displays Previous and Next navigation links at the bottom.
|
|
407
538
|
These follow the flat chapter order (including across parts and appendix).
|
|
408
|
-
|
|
539
|
+
Cover pages do not show navigation.
|
|
540
|
+
|
|
541
|
+
== Write Command ==
|
|
542
|
+
|
|
543
|
+
ligarb write [BRIEF] Generate a complete book using AI (Claude).
|
|
544
|
+
BRIEF defaults to 'brief.yml' in the current directory.
|
|
545
|
+
Reads the brief, sends a prompt to Claude, and builds
|
|
546
|
+
the generated book. Files are created in the same
|
|
547
|
+
directory as brief.yml.
|
|
548
|
+
|
|
549
|
+
ligarb write --init [DIR] Create a brief.yml template.
|
|
550
|
+
If DIR is given, creates DIR/brief.yml (mkdir as needed).
|
|
551
|
+
If omitted, creates brief.yml in the current directory.
|
|
552
|
+
|
|
553
|
+
ligarb write --no-build Generate files only, skip the build step.
|
|
554
|
+
|
|
555
|
+
brief.yml fields:
|
|
556
|
+
|
|
557
|
+
title: (required) The book title.
|
|
558
|
+
language: (optional) Language. Default: "ja".
|
|
559
|
+
audience: (optional) Target audience (used in the prompt).
|
|
560
|
+
notes: (optional) Additional instructions for Claude (free text).
|
|
561
|
+
author: (optional) Passed through to book.yml.
|
|
562
|
+
output_dir: (optional) Passed through to book.yml.
|
|
563
|
+
chapter_numbers: (optional) Passed through to book.yml.
|
|
564
|
+
style: (optional) Passed through to book.yml.
|
|
565
|
+
repository: (optional) Passed through to book.yml.
|
|
566
|
+
|
|
567
|
+
The book is generated in the directory containing brief.yml.
|
|
568
|
+
Example: 'ligarb write ruby_book/brief.yml' creates files in ruby_book/.
|
|
569
|
+
|
|
570
|
+
Requires the 'claude' CLI to be installed.
|
|
409
571
|
SPEC
|
|
410
572
|
end
|
|
573
|
+
|
|
574
|
+
def print_spec
|
|
575
|
+
puts spec_text
|
|
576
|
+
end
|
|
411
577
|
end
|
|
412
578
|
end
|
data/lib/ligarb/config.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Ligarb
|
|
|
13
13
|
# children: array of StructEntry (for :part and :appendix_group)
|
|
14
14
|
|
|
15
15
|
attr_reader :title, :author, :language, :output_dir, :base_dir,
|
|
16
|
-
:chapter_numbers, :structure, :style, :repository
|
|
16
|
+
:chapter_numbers, :structure, :style, :repository,
|
|
17
|
+
:ai_generated, :footer, :bibliography, :sources
|
|
17
18
|
|
|
18
19
|
def initialize(path)
|
|
19
20
|
@base_dir = File.dirname(File.expand_path(path))
|
|
@@ -28,6 +29,10 @@ module Ligarb
|
|
|
28
29
|
@chapter_numbers = data.fetch("chapter_numbers", true)
|
|
29
30
|
@style = data.fetch("style", nil)
|
|
30
31
|
@repository = data.fetch("repository", nil)
|
|
32
|
+
@ai_generated = data.fetch("ai_generated", false)
|
|
33
|
+
@footer = data.fetch("footer", nil)
|
|
34
|
+
@bibliography = data.fetch("bibliography", nil)
|
|
35
|
+
@sources = parse_sources(data.fetch("sources", []))
|
|
31
36
|
@structure = parse_structure(data["chapters"])
|
|
32
37
|
end
|
|
33
38
|
|
|
@@ -39,10 +44,25 @@ module Ligarb
|
|
|
39
44
|
@style ? File.join(@base_dir, @style) : nil
|
|
40
45
|
end
|
|
41
46
|
|
|
47
|
+
def bibliography_path
|
|
48
|
+
@bibliography ? File.join(@base_dir, @bibliography) : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
def appendix_label
|
|
43
52
|
@language == "ja" ? "付録" : "Appendix"
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
def effective_footer
|
|
56
|
+
return @footer if @footer
|
|
57
|
+
return nil unless @ai_generated
|
|
58
|
+
|
|
59
|
+
if @language == "ja"
|
|
60
|
+
"この章の内容は AI によって生成されました。正確性は保証されません。"
|
|
61
|
+
else
|
|
62
|
+
"This chapter was generated by AI. Accuracy is not guaranteed."
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
46
66
|
# Returns a flat list of all chapter file paths (excluding part title pages)
|
|
47
67
|
def chapter_paths
|
|
48
68
|
collect_chapter_paths(@structure)
|
|
@@ -55,6 +75,24 @@ module Ligarb
|
|
|
55
75
|
|
|
56
76
|
private
|
|
57
77
|
|
|
78
|
+
Source = Struct.new(:path, :label, keyword_init: true)
|
|
79
|
+
|
|
80
|
+
def parse_sources(entries)
|
|
81
|
+
return [] unless entries.is_a?(Array)
|
|
82
|
+
|
|
83
|
+
entries.map do |entry|
|
|
84
|
+
case entry
|
|
85
|
+
when String
|
|
86
|
+
Source.new(path: File.join(@base_dir, entry), label: File.basename(entry))
|
|
87
|
+
when Hash
|
|
88
|
+
path = entry["path"] or abort "Error: source entry missing 'path'"
|
|
89
|
+
Source.new(path: File.join(@base_dir, path), label: entry.fetch("label", File.basename(path)))
|
|
90
|
+
else
|
|
91
|
+
abort "Error: invalid source entry: #{entry.inspect}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
58
96
|
def parse_structure(entries)
|
|
59
97
|
entries.map do |entry|
|
|
60
98
|
case entry
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin Fiddle wrapper for Linux inotify(7).
|
|
4
|
+
# Non-Linux platforms will get LoadError from dlsym lookup.
|
|
5
|
+
|
|
6
|
+
require "fiddle"
|
|
7
|
+
|
|
8
|
+
module Ligarb
|
|
9
|
+
module Inotify
|
|
10
|
+
LIBC = Fiddle.dlopen(nil)
|
|
11
|
+
|
|
12
|
+
InotifyInit1 = Fiddle::Function.new(
|
|
13
|
+
LIBC["inotify_init1"],
|
|
14
|
+
[Fiddle::TYPE_INT],
|
|
15
|
+
Fiddle::TYPE_INT
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
InotifyAddWatch = Fiddle::Function.new(
|
|
19
|
+
LIBC["inotify_add_watch"],
|
|
20
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
|
|
21
|
+
Fiddle::TYPE_INT
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
InotifyRmWatch = Fiddle::Function.new(
|
|
25
|
+
LIBC["inotify_rm_watch"],
|
|
26
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT],
|
|
27
|
+
Fiddle::TYPE_INT
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# inotify event masks
|
|
31
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
32
|
+
IN_CLOEXEC = 0x00080000 # O_CLOEXEC on x86_64
|
|
33
|
+
|
|
34
|
+
# struct inotify_event fixed part: int wd, uint32 mask, uint32 cookie, uint32 len
|
|
35
|
+
EVENT_HEADER_SIZE = 16
|
|
36
|
+
|
|
37
|
+
# Watch a file for writes. Yields each time the file is written and closed.
|
|
38
|
+
# Blocks the calling thread. Caller should wrap in Thread.new.
|
|
39
|
+
def self.watch_file(path, &block)
|
|
40
|
+
fd = InotifyInit1.call(IN_CLOEXEC)
|
|
41
|
+
raise SystemCallError.new("inotify_init1", Fiddle.last_error) if fd < 0
|
|
42
|
+
|
|
43
|
+
io = IO.for_fd(fd, autoclose: true)
|
|
44
|
+
wd = add_watch(fd, path)
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
# IO.select releases GVL while waiting
|
|
48
|
+
IO.select([io])
|
|
49
|
+
buf = io.read_nonblock(4096, exception: false)
|
|
50
|
+
next if buf == :wait_readable || buf.nil?
|
|
51
|
+
|
|
52
|
+
# Parse events — may contain multiple events
|
|
53
|
+
offset = 0
|
|
54
|
+
while offset + EVENT_HEADER_SIZE <= buf.bytesize
|
|
55
|
+
_wd, mask, _cookie, name_len = buf.byteslice(offset, EVENT_HEADER_SIZE).unpack("iIII")
|
|
56
|
+
offset += EVENT_HEADER_SIZE + name_len
|
|
57
|
+
|
|
58
|
+
if mask & IN_CLOSE_WRITE != 0
|
|
59
|
+
block.call
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
ensure
|
|
66
|
+
io&.close rescue nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.add_watch(fd, path)
|
|
70
|
+
wd = InotifyAddWatch.call(fd, path, IN_CLOSE_WRITE)
|
|
71
|
+
raise SystemCallError.new("inotify_add_watch: #{path}", Fiddle.last_error) if wd < 0
|
|
72
|
+
wd
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ligarb
|
|
8
|
+
class ReviewStore
|
|
9
|
+
def initialize(base_dir)
|
|
10
|
+
@dir = File.join(base_dir, ".ligarb", "reviews")
|
|
11
|
+
FileUtils.mkdir_p(@dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list
|
|
15
|
+
Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
|
|
16
|
+
.compact
|
|
17
|
+
.sort_by { |r| r["created_at"] }
|
|
18
|
+
.map { |r| summary(r) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(id)
|
|
22
|
+
path = file_path(id)
|
|
23
|
+
return nil unless File.exist?(path)
|
|
24
|
+
read_json(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create(context:, message:)
|
|
28
|
+
id = SecureRandom.uuid
|
|
29
|
+
now = Time.now.utc.iso8601
|
|
30
|
+
|
|
31
|
+
review = {
|
|
32
|
+
"id" => id,
|
|
33
|
+
"status" => "open",
|
|
34
|
+
"created_at" => now,
|
|
35
|
+
"context" => context,
|
|
36
|
+
"messages" => [
|
|
37
|
+
{ "role" => "user", "content" => message, "timestamp" => now }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write_json(id, review)
|
|
42
|
+
review
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_message(id, role:, content:)
|
|
46
|
+
review = get(id)
|
|
47
|
+
return nil unless review
|
|
48
|
+
|
|
49
|
+
review["messages"] << {
|
|
50
|
+
"role" => role,
|
|
51
|
+
"content" => content,
|
|
52
|
+
"timestamp" => Time.now.utc.iso8601
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
write_json(id, review)
|
|
56
|
+
review
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_context_files(id, files)
|
|
60
|
+
review = get(id)
|
|
61
|
+
return nil unless review
|
|
62
|
+
|
|
63
|
+
existing = review.dig("context", "uploaded_files") || []
|
|
64
|
+
review["context"]["uploaded_files"] = existing + files
|
|
65
|
+
write_json(id, review)
|
|
66
|
+
review
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_status(id, status)
|
|
70
|
+
review = get(id)
|
|
71
|
+
return nil unless review
|
|
72
|
+
|
|
73
|
+
review["status"] = status
|
|
74
|
+
write_json(id, review)
|
|
75
|
+
review
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(id)
|
|
79
|
+
path = file_path(id)
|
|
80
|
+
return false unless File.exist?(path)
|
|
81
|
+
File.delete(path)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def file_path(id)
|
|
88
|
+
File.join(@dir, "#{id}.json")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_json(path)
|
|
92
|
+
JSON.parse(File.read(path))
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write_json(id, data)
|
|
98
|
+
File.write(file_path(id), JSON.pretty_generate(data))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def summary(review)
|
|
102
|
+
{
|
|
103
|
+
"id" => review["id"],
|
|
104
|
+
"status" => review["status"],
|
|
105
|
+
"created_at" => review["created_at"],
|
|
106
|
+
"context" => review["context"],
|
|
107
|
+
"message_count" => review["messages"].size,
|
|
108
|
+
"last_message" => review["messages"].last
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|