From 3a41872934188e4c5cf7198c24b58ca369d038a3 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Sun, 3 May 2026 17:36:25 +0900 Subject: [PATCH] Implement history artifact UI cut Closes #81 --- .gitignore | 2 +- cmd/server/main.go | 20 +- cmd/server/main_test.go | 34 + ...02-multi-persona-multi-format-extension.md | 19 +- .../issue-27-28-history-artifacts-api.md | 113 ++++ .../next-implementation-cut.md | 28 +- ...ssue-27-28-history-artifacts-2026-05-03.md | 79 +++ internal/handlers/workflow.go | 295 +++++++++ internal/handlers/workflow_history_test.go | 148 +++++ .../repository/memory/workflow.go | 33 + .../repository/sqlite/workflow.go | 156 +++++ static/css/style.css | 123 ++++ static/history_ui_test.go | 57 ++ static/index.html | 38 +- static/js/script.js | 601 ++++++++++++++++++ 15 files changed, 1727 insertions(+), 19 deletions(-) create mode 100644 cmd/server/main_test.go create mode 100644 docs/implementation-plans/issue-27-28-history-artifacts-api.md create mode 100644 docs/validation/issue-27-28-history-artifacts-2026-05-03.md create mode 100644 internal/handlers/workflow_history_test.go create mode 100644 static/history_ui_test.go diff --git a/.gitignore b/.gitignore index 5dbe95b..9c277ee 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ Thumbs.db bin/ dist/ tmp/ -server +/server data/ # ログ関連 diff --git a/cmd/server/main.go b/cmd/server/main.go index 345d7c2..0c6b261 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -24,8 +24,15 @@ func main() { // ルーターの設定 r := mux.NewRouter() + registerRoutes(r) - // 静的ファイルの配信 (staticディレクトリをルートとして提供) + log.Printf("Starting server on port %s...", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + log.Fatal(err) + } +} + +func registerRoutes(r *mux.Router) { fs := http.FileServer(http.Dir("static")) r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)) r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { @@ -39,11 +46,17 @@ func main() { r.HandleFunc("/api/config/storage", handlers.UpdateStorageConfigHandler).Methods("PATCH") r.HandleFunc("/api/personas", handlers.ListPersonasHandler).Methods("GET") r.HandleFunc("/api/formats", handlers.ListFormatsHandler).Methods("GET") + r.HandleFunc("/api/history", handlers.ListWorkflowArtifactsHandler).Methods("GET") + r.HandleFunc("/api/workflow/artifacts", handlers.ListWorkflowArtifactsHandler).Methods("GET") + r.HandleFunc("/api/author-style", handlers.ListAuthorStylesHandler).Methods("GET") r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST") r.HandleFunc("/api/author-style/analyze", handlers.AnalyzeAuthorStyleHandler).Methods("POST") r.HandleFunc("/api/author-style/{id}", handlers.GetAuthorStyleHandler).Methods("GET") r.HandleFunc("/api/brief-sessions/templates", handlers.GetBriefSessionTemplateHandler).Methods("GET") + r.HandleFunc("/api/brief-sessions", handlers.ListBriefSessionsHandler).Methods("GET") r.HandleFunc("/api/brief-sessions", handlers.CreateBriefSessionHandler).Methods("POST") + r.HandleFunc("/api/briefs", handlers.ListBriefArtifactsHandler).Methods("GET") + r.HandleFunc("/api/briefs/{id}", handlers.GetBriefArtifactHandler).Methods("GET") r.HandleFunc("/api/brief-sessions/{id}", handlers.GetBriefSessionHandler).Methods("GET") r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST") r.HandleFunc("/api/brief-sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST") @@ -55,9 +68,4 @@ func main() { r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "static/index.html") }) - - log.Printf("Starting server on port %s...", port) - if err := http.ListenAndServe(":"+port, r); err != nil { - log.Fatal(err) - } } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..24889ed --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + "testing" + + "github.com/gorilla/mux" +) + +func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) { + router := mux.NewRouter() + registerRoutes(router) + + for _, tt := range []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api/history"}, + {method: http.MethodGet, path: "/api/workflow/artifacts"}, + {method: http.MethodGet, path: "/api/author-style"}, + {method: http.MethodGet, path: "/api/brief-sessions"}, + {method: http.MethodGet, path: "/api/briefs"}, + {method: http.MethodGet, path: "/api/briefs/session-1"}, + } { + request, err := http.NewRequest(tt.method, tt.path, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + var match mux.RouteMatch + if !router.Match(request, &match) { + t.Fatalf("%s %s did not match a route", tt.method, tt.path) + } + } +} diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md index 635d58b..beef122 100644 --- a/docs/adrs/0002-multi-persona-multi-format-extension.md +++ b/docs/adrs/0002-multi-persona-multi-format-extension.md @@ -166,6 +166,15 @@ Additions: Existing `/api/generate` remains a compatibility facade. +Implemented history/artifact read subset as of the #27/#28 cut: + +- `GET /api/history` and `GET /api/workflow/artifacts` — combined reusable workflow artifact index with `style_guides`, `sessions`, and `briefs`. +- `GET /api/author-style` — saved writing style-guide list for picker UIs. +- `GET /api/brief-sessions` — saved interview session summaries. +- `GET /api/briefs` and `GET /api/briefs/{id}` — completed brief artifacts for readable card rendering and reuse. + +These endpoints are intentionally narrower than the future project/article/draft artifact surface described above. They do not implement add-persona authoring UI, project/article browsing, draft version browsing, or broader edit persistence semantics. + ## Testing Strategy - Unit tests added per format validator, per persona seed, per fetcher. @@ -212,6 +221,7 @@ Current implementation status as of 2026-05-03: - Phase B2/B3/B4 are implemented: historical source acquisition works for note, Zenn, Qiita, Cor RSS, and Cor GitHub Markdown; all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Validation is recorded in [Issue 22 source fetcher validation](../validation/issue-22-source-fetchers-2026-05-02.md) and [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). - Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)). - Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)). +- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md). - Phase D1 is implemented and merged: handler tests now cover template selection, edit/fork errors, SSE follow-up and draft paths, completed-session draft fallback, regenerate-section context recovery, Analyze/Generate compatibility handlers, and SQLite driver selection. `go test ./internal/handlers -cover` reports 80%+ statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)). - Runtime runner support is implemented and merged: `cmd/scenario/live_media_matrix` reads the offline matrix, emits planned aggregate JSON/Markdown by default, and executes live Evo X2 draft runs only when `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` is used ([#57](https://github.com/terisuke/note_maker/issues/57)). - The 2026-05-03 browser 500 analysis showed an implementation drift: plain web-app startup still defaulted to workstation-local `127.0.0.1:8081`, while this ADR requires Evo X2 Tailnet as primary. Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the default order to Evo X2 Ollama over Tailnet → Evo X2 llama.cpp → workstation-local llama.cpp and makes the UI show the actual endpoint/model reported by SSE. @@ -225,8 +235,9 @@ Current implementation status as of 2026-05-03: Near-term execution order: 1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check. -2. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work. -3. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate. +2. Land the #27/#28 history/artifact read cut and add Browser E2E coverage ([#13](https://github.com/terisuke/note_maker/issues/13)) for history opening, readable cards, and the existing edit/fork/stream/regenerate flows. +3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, and richer project/article/draft history surfaces from the #26 SQLite schema. +4. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate. ## Tracked issues @@ -242,8 +253,8 @@ Filed 2026-05-02 as part of the PR that introduced this ADR. - B4 — [#24](https://github.com/terisuke/note_maker/issues/24) Seed persona library with `terisuke` and `cloudia` profiles. Implemented for the built-in registry: seeds include sources, default formats, and voice notes; live source re-analysis remains under [#22](https://github.com/terisuke/note_maker/issues/22). - B5 — [#25](https://github.com/terisuke/note_maker/issues/25) Format- and persona-aware fixed question sets - C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store. -- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI -- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards +- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — implemented in the current cut for saved style-guide and brief-session reuse through `履歴から再開`, backed by `GET /api/workflow/artifacts`, `GET /api/author-style`, and `GET /api/brief-sessions`. Add-persona authoring UI and broader edit-persistence expectations remain follow-up work. +- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Rich project/article/draft artifact browsing remains a later Phase C layer on top of the #26 SQLite schema. - D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` — implemented in the current cut with 80.0% handler package coverage. - Runtime runner — [#57](https://github.com/terisuke/note_maker/issues/57) Add live LLM media-matrix runner and aggregate evaluator, feeding [#40](https://github.com/terisuke/note_maker/issues/40) — implemented in the current cut. - Runtime stabilization epic — [#40](https://github.com/terisuke/note_maker/issues/40) Stabilize Tailnet Evo X2 draft quality and runtime metrics. #70-#73 provide the prerequisite validation and diagnostics. [#74](https://github.com/terisuke/note_maker/issues/74) has passed the bounded Cloudia/Zenn and Cloudia/Qiita proofs plus the final `5/5` publishing-target matrix. diff --git a/docs/implementation-plans/issue-27-28-history-artifacts-api.md b/docs/implementation-plans/issue-27-28-history-artifacts-api.md new file mode 100644 index 0000000..30991ed --- /dev/null +++ b/docs/implementation-plans/issue-27-28-history-artifacts-api.md @@ -0,0 +1,113 @@ +# Issue 27/28 History And Artifact API Cut + +Date: 2026-05-03 +Branch: `codex/issue27-28-history-artifacts` + +## Purpose + +This cut makes persisted workflow memory visible enough for day-to-day drafting: + +- reuse a saved writing style guide without re-fetching sources, +- reopen a saved interview session, +- read completed briefs as cards instead of raw JSON, +- keep the raw Markdown/JSON available for audit. + +It is intentionally smaller than the full Phase C vision in ADR 0002. Project/article/draft browsing is left as a follow-up on top of the richer SQLite schema. + +## Implemented Store Surface + +The handler-facing `workflowStoreBackend` now exposes the current reusable workflow artifacts: + +```go +ListAuthorStyles() ([]authorstyle.AnalyzeResult, error) +ListSessions() ([]brief.ArticleBriefSession, error) +ListBriefs() (map[string]brief.ArticleBrief, error) +``` + +Both the JSON/memory store and SQLite store implement these methods. + +SQLite also gained entry-point list methods for later project history work: + +```go +ListProjects() ([]ProjectRecord, error) +ListArticlesByProject(projectID string) ([]ArticleRecord, error) +``` + +Those SQLite methods are not yet surfaced in the web UI. + +## Implemented HTTP Surface + +The web UI uses the combined index first: + +- `GET /api/workflow/artifacts` +- `GET /api/history` as an alias + +Response: + +```json +{ + "style_guides": [], + "sessions": [], + "briefs": [] +} +``` + +Focused read endpoints are also available: + +- `GET /api/author-style` - saved style-guide artifacts +- `GET /api/author-style/{id}` - existing detail endpoint by analysis/profile/guide id +- `GET /api/brief-sessions` - saved interview session summaries +- `GET /api/brief-sessions/{id}` - existing session detail endpoint +- `GET /api/briefs` - completed brief artifacts +- `GET /api/briefs/{id}` - completed brief artifact by session id + +## Implemented UI Surface + +`static/index.html` now includes a `履歴から再開` area: + +- persona filter for history selection, +- saved style-guide picker, +- saved interview-session picker, +- refresh/open/clear controls, +- loading, empty, and error states. + +`static/js/script.js` restores selected history into the existing workflow state: + +- selected style guide sets `profileId`, guide metadata, and style card, +- selected session restores `sessionId`, transcript state, completed brief, persona/format mode, and draft-generation readiness, +- selecting only a session resolves its style guide via `style_profile_id`. + +## Human-Readable Artifacts + +The UI now renders: + +- style guide cards with profile/guide/article metadata and parsed Markdown sections, +- article brief cards with theme, reader, opening episode, action, must-include, context, exclusions, structure, tone, custom answers, and deep-dive answers. + +Raw Markdown and JSON are still available behind disclosure controls. This keeps review/debug data accessible without making it the default user experience. + +## Tests + +Added: + +- `internal/handlers/workflow_history_test.go` +- `cmd/server/main_test.go` +- `static/history_ui_test.go` + +Validation commands: + +```sh +node --check static/js/script.js +go test ./... +git diff --check +``` + +## Remaining Phase C Work + +Not included in this cut: + +- add-persona authoring UI, +- edit persistence beyond the existing fork-on-edit/session/brief save path, +- project/article/draft history browsing, +- draft version and section-regeneration artifact browsing, +- browser E2E for the new history flow under Issue #13. diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md index 0beece0..6502df0 100644 --- a/docs/implementation-plans/next-implementation-cut.md +++ b/docs/implementation-plans/next-implementation-cut.md @@ -25,10 +25,18 @@ Implemented and merged: - [#61](https://github.com/terisuke/note_maker/issues/61) / [PR #62](https://github.com/terisuke/note_maker/pull/62) — workflow storage mode can be inspected and switched from the settings UI; environment-locked deployments remain read-only. - [#70](https://github.com/terisuke/note_maker/issues/70)-[#73](https://github.com/terisuke/note_maker/issues/73) prerequisite slice for runtime evaluation — interview-template coverage, failed draft artifact preservation, bounded format repair, and output-format-specific scenario gates are in place for staged #74 reruns. +Implemented in the current #27/#28 history/artifact cut: + +- [#27](https://github.com/terisuke/note_maker/issues/27) — first saved-history UI cut: `履歴から再開`, persona-scoped saved style-guide and brief-session selectors, and open/clear/refresh controls. +- [#28](https://github.com/terisuke/note_maker/issues/28) — first readable artifact cut: style-guide cards and article-brief cards replace raw-only `pre` output while keeping Markdown/JSON details available. +- History read API surface — `GET /api/history`, `GET /api/workflow/artifacts`, `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. +- Store list support — memory and SQLite now expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also has `ListProjects` and `ListArticlesByProject` for the richer #26 schema. +- Focused tests — `internal/handlers/workflow_history_test.go` covers saved artifact responses and brief detail errors; `static/history_ui_test.go` locks the frontend contract. +- Validation — [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md). + Open and active: - Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work. -- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28). - Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13). - Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40), now satisfied for the current note/Qiita/Zenn/Cor blog publishing-target acceptance scope by the 2026-05-03 full Tailnet Evo X2 matrix. - Runtime evaluation sub-issue [#74](https://github.com/terisuke/note_maker/issues/74), satisfied by the staged reruns and the final `5/5` full matrix pass. @@ -38,6 +46,12 @@ Open and active: - Interview usability fixed before measurement: [#66](https://github.com/terisuke/note_maker/issues/66), with details in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md). - Style-source switching fixed before measurement: [#68](https://github.com/terisuke/note_maker/issues/68), with details in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md). +Remaining Phase C gaps after the current #27/#28 cut: + +- Add-persona authoring UI is not implemented; the current UI consumes seeded personas and saved artifacts. +- Broader edit persistence called out in the issue text is not implemented beyond the existing fork-on-edit/session/brief save paths. +- Project/article/draft artifact browsing from SQLite's normalized #26 schema is not exposed in the web UI yet; this cut intentionally uses style guides, sessions, and completed briefs as the reusable history surface. + ## Final evaluation target The final integrated evaluation should use `cmd/scenario/media_matrix` as the input matrix, then run live Evo X2 Tailnet draft scenarios for: @@ -109,13 +123,15 @@ Use subagents with disjoint write scopes when implementation resumes: | Lane | Issue | Subagent role | Write scope | Done when | |---|---|---|---|---| | A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts | -| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state | -| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered | +| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | done for this cut | style-guide/session history picker and readable brief/style cards use persisted workflow state | +| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, history open, readable cards, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered | +| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing are split from the #27/#28 first cut | Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files. ## Recommended order -1. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts. -2. Continue #27/#28 and #13 in parallel as product-readiness work: history picker, readable brief/style cards, and browser E2E coverage are now the highest-value next tasks. -3. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate. +1. Land the current #27/#28 history/artifact cut with its validation doc, then wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming, and regenerate-section coverage goals. +2. Split the remaining Phase C work into explicit follow-up issues before implementation: add-persona authoring UI, broader edit persistence semantics, and project/article/draft artifact browsing from the #26 SQLite schema. +3. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts. +4. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate. diff --git a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md new file mode 100644 index 0000000..67b27be --- /dev/null +++ b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md @@ -0,0 +1,79 @@ +# Issue 27/28 history and artifact UI/API validation + +Date: 2026-05-03 +Branch: `codex/issue27-28-history-artifacts` + +## Scope + +This validation covers: + +- [#27](https://github.com/terisuke/note_maker/issues/27) first saved-history picker cut. +- [#28](https://github.com/terisuke/note_maker/issues/28) first human-readable style-guide and brief artifact card cut. + +It deliberately does not claim completion for add-persona authoring UI, broader edit persistence, project/article/draft history browsing, draft version browsing, or Browser E2E coverage. + +## What changed + +- Added history read routes: + - `GET /api/history` + - `GET /api/workflow/artifacts` + - `GET /api/author-style` + - `GET /api/brief-sessions` + - `GET /api/briefs` + - `GET /api/briefs/{id}` +- Added store list methods: + - memory: `ListAuthorStyles`, `ListSessions`, `ListBriefs` + - SQLite: `ListAuthorStyles`, `ListSessions`, `ListBriefs`, `ListProjects`, `ListArticlesByProject` +- Added the web UI `履歴から再開` section with saved style-guide/session pickers. +- Added readable style-guide and article-brief cards while keeping raw Markdown/JSON disclosures. +- Added focused handler and static UI contract tests. + +## API Contract + +`GET /api/workflow/artifacts` and `GET /api/history` return one reusable index: + +```json +{ + "style_guides": [], + "sessions": [], + "briefs": [] +} +``` + +The UI then opens details through existing or focused endpoints: + +- style guide detail: `GET /api/author-style/{id}` +- session detail: `GET /api/brief-sessions/{id}` +- brief detail: `GET /api/briefs/{id}` + +## Verification + +Command: + +```sh +go test ./internal/handlers ./static +``` + +Result: + +```text +ok github.com/teradakousuke/note_maker/internal/handlers (cached) +ok github.com/teradakousuke/note_maker/static (cached) +``` + +## Acceptance Status + +- Saved style guides can be listed for picker UIs: done. +- Saved interview sessions can be listed with completion and brief availability metadata: done. +- Completed briefs can be listed and retrieved by session id: done. +- Combined workflow artifact index returns style guides, sessions, and briefs: done. +- UI includes `履歴から再開`, refresh/open/clear controls, saved style/session selects, and status messaging: done. +- Style guide is rendered as a readable card and raw Markdown remains available: done. +- Article brief is rendered as a readable card and raw JSON remains available: done. + +## Remaining Work + +- Add-persona authoring UI is still unimplemented. +- Broader edit persistence beyond the existing fork-on-edit/session save flow is still unimplemented. +- Project/article/draft history browsing from SQLite remains unimplemented in the UI. +- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13). diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go index a99f51c..ef1d278 100644 --- a/internal/handlers/workflow.go +++ b/internal/handlers/workflow.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "strings" "sync" "time" @@ -31,10 +32,13 @@ var workflowStore = newWorkflowStore() type workflowStoreBackend interface { SaveAuthorStyle(authorstyleapp.AnalyzeResult) error GetAuthorStyle(string) (authorstyleapp.AnalyzeResult, bool) + ListAuthorStyles() ([]authorstyleapp.AnalyzeResult, error) SaveSession(briefdomain.ArticleBriefSession) error GetSession(string) (briefdomain.ArticleBriefSession, bool) + ListSessions() ([]briefdomain.ArticleBriefSession, error) SaveBrief(string, briefdomain.ArticleBrief) error GetBrief(string) (briefdomain.ArticleBrief, bool) + ListBriefs() (map[string]briefdomain.ArticleBrief, error) GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) } @@ -78,11 +82,31 @@ type authorStyleResponse struct { GuideID string `json:"guide_id"` GuideMarkdown string `json:"guide_markdown"` ArticleCount int `json:"article_count"` + CreatedAt string `json:"created_at,omitempty"` Source any `json:"source"` Profile any `json:"profile"` Guide any `json:"guide"` } +type authorStyleListResponse struct { + StyleGuides []styleGuideArtifactResponse `json:"style_guides"` +} + +type styleGuideArtifactResponse struct { + ID string `json:"id"` + AnalysisID string `json:"analysis_id"` + ProfileID string `json:"profile_id"` + GuideID string `json:"guide_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ArticleCount int `json:"article_count"` + GuideMarkdown string `json:"guide_markdown"` + Source authordomain.AuthorSource `json:"source"` + Profile authordomain.AuthorStyleProfile `json:"profile"` + Guide authordomain.WritingStyleGuide `json:"guide"` +} + type createBriefSessionRequest struct { StyleProfileID string `json:"style_profile_id"` SessionID string `json:"session_id"` @@ -116,6 +140,47 @@ type briefSessionResponse struct { Answers []briefdomain.BriefAnswer `json:"answers"` } +type briefSessionListResponse struct { + Sessions []briefSessionSummaryResponse `json:"sessions"` +} + +type briefSessionSummaryResponse struct { + SessionID string `json:"session_id"` + StyleProfileID string `json:"style_profile_id"` + PersonaID string `json:"persona_id"` + OutputFormatID string `json:"output_format_id"` + ParentSessionID string `json:"parent_session_id,omitempty"` + Phase string `json:"phase"` + Completed bool `json:"completed"` + AnswerCount int `json:"answer_count"` + QuestionCount int `json:"question_count"` + BriefAvailable bool `json:"brief_available"` + Title string `json:"title,omitempty"` +} + +type briefArtifactListResponse struct { + Briefs []briefArtifactResponse `json:"briefs"` +} + +type briefArtifactResponse struct { + SessionID string `json:"session_id"` + StyleProfileID string `json:"style_profile_id"` + PersonaID string `json:"persona_id"` + OutputFormatID string `json:"output_format_id"` + ParentSessionID string `json:"parent_session_id,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + AnswerCount int `json:"answer_count"` + DeepDiveCount int `json:"deep_dive_count"` + Brief briefdomain.ArticleBrief `json:"brief"` +} + +type workflowArtifactsResponse struct { + StyleGuides []styleGuideArtifactResponse `json:"style_guides"` + Sessions []briefSessionSummaryResponse `json:"sessions"` + Briefs []briefArtifactResponse `json:"briefs"` +} + type briefSessionTemplateResponse struct { PersonaID string `json:"persona_id"` OutputFormatID string `json:"output_format_id"` @@ -615,6 +680,19 @@ func buildPresetAuthorStyle(persona personadomain.Persona, format outputformat.O }, nil } +// ListAuthorStylesHandler returns stored style-guide artifacts for picker UIs. +func ListAuthorStylesHandler(w http.ResponseWriter, r *http.Request) { + results, err := workflowStore.ListAuthorStyles() + if err != nil { + respondWithError(w, "AUTHOR_STYLE_LIST_FAILED", "Failed to list author styles", err.Error(), http.StatusInternalServerError) + return + } + sortAuthorStyles(results) + respondWithJSON(w, http.StatusOK, authorStyleListResponse{ + StyleGuides: toStyleGuideArtifactResponses(results), + }) +} + // GetAuthorStyleHandler returns a stored author style analysis result. func GetAuthorStyleHandler(w http.ResponseWriter, r *http.Request) { id := pathValue(r, "id") @@ -626,6 +704,24 @@ func GetAuthorStyleHandler(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, http.StatusOK, toAuthorStyleResponse(result)) } +// ListBriefSessionsHandler returns saved interview sessions for project history UIs. +func ListBriefSessionsHandler(w http.ResponseWriter, r *http.Request) { + sessions, err := workflowStore.ListSessions() + if err != nil { + respondWithError(w, "BRIEF_SESSION_LIST_FAILED", "Failed to list brief sessions", err.Error(), http.StatusInternalServerError) + return + } + briefs, err := workflowStore.ListBriefs() + if err != nil { + respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError) + return + } + sortBriefSessions(sessions) + respondWithJSON(w, http.StatusOK, briefSessionListResponse{ + Sessions: toBriefSessionSummaryResponses(sessions, briefs), + }) +} + // CreateBriefSessionHandler starts the fixed-question interview. func CreateBriefSessionHandler(w http.ResponseWriter, r *http.Request) { var req createBriefSessionRequest @@ -694,6 +790,56 @@ func GetBriefSessionHandler(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, http.StatusOK, toBriefSessionResponse(result)) } +// ListBriefArtifactsHandler returns completed brief artifacts for reuse. +func ListBriefArtifactsHandler(w http.ResponseWriter, r *http.Request) { + briefs, err := workflowStore.ListBriefs() + if err != nil { + respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusOK, briefArtifactListResponse{ + Briefs: listBriefArtifactResponses(briefs), + }) +} + +// GetBriefArtifactHandler returns one completed brief artifact by session ID. +func GetBriefArtifactHandler(w http.ResponseWriter, r *http.Request) { + sessionID := pathValue(r, "id") + articleBrief, ok := workflowStore.GetBrief(sessionID) + if !ok { + respondWithError(w, "BRIEF_NOT_FOUND", "Brief was not found", sessionID, http.StatusNotFound) + return + } + session, sessionOK := workflowStore.GetSession(sessionID) + respondWithJSON(w, http.StatusOK, toBriefArtifactResponse(sessionID, articleBrief, session, sessionOK)) +} + +// ListWorkflowArtifactsHandler returns all currently reusable workflow artifacts. +func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) { + styles, err := workflowStore.ListAuthorStyles() + if err != nil { + respondWithError(w, "AUTHOR_STYLE_LIST_FAILED", "Failed to list author styles", err.Error(), http.StatusInternalServerError) + return + } + briefs, err := workflowStore.ListBriefs() + if err != nil { + respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError) + return + } + sessions, err := workflowStore.ListSessions() + if err != nil { + respondWithError(w, "BRIEF_SESSION_LIST_FAILED", "Failed to list brief sessions", err.Error(), http.StatusInternalServerError) + return + } + sortAuthorStyles(styles) + sortBriefSessions(sessions) + respondWithJSON(w, http.StatusOK, workflowArtifactsResponse{ + StyleGuides: toStyleGuideArtifactResponses(styles), + Sessions: toBriefSessionSummaryResponses(sessions, briefs), + Briefs: listBriefArtifactResponses(briefs), + }) +} + // EditBriefAnswerHandler creates a new child session from an edited past answer. func EditBriefAnswerHandler(w http.ResponseWriter, r *http.Request) { var req editBriefAnswerRequest @@ -1156,12 +1302,77 @@ func toAuthorStyleResponse(result authorstyleapp.AnalyzeResult) authorStyleRespo GuideID: result.Guide.ID, GuideMarkdown: result.Guide.Markdown, ArticleCount: result.ArticleCount, + CreatedAt: formatOptionalTime(result.CreatedAt), + Source: result.Source, + Profile: result.Profile, + Guide: result.Guide, + } +} + +func toStyleGuideArtifactResponses(results []authorstyleapp.AnalyzeResult) []styleGuideArtifactResponse { + items := make([]styleGuideArtifactResponse, 0, len(results)) + for _, result := range results { + items = append(items, toStyleGuideArtifactResponse(result)) + } + return items +} + +func toStyleGuideArtifactResponse(result authorstyleapp.AnalyzeResult) styleGuideArtifactResponse { + return styleGuideArtifactResponse{ + ID: result.Guide.ID, + AnalysisID: result.ID, + ProfileID: result.Profile.ID, + GuideID: result.Guide.ID, + Title: styleGuideTitle(result), + Description: styleGuideDescription(result), + CreatedAt: formatOptionalTime(result.CreatedAt), + ArticleCount: result.ArticleCount, + GuideMarkdown: result.Guide.Markdown, Source: result.Source, Profile: result.Profile, Guide: result.Guide, } } +func sortAuthorStyles(results []authorstyleapp.AnalyzeResult) { + sort.SliceStable(results, func(i, j int) bool { + left := results[i].CreatedAt + right := results[j].CreatedAt + if !left.Equal(right) { + return left.After(right) + } + return results[i].ID < results[j].ID + }) +} + +func styleGuideTitle(result authorstyleapp.AnalyzeResult) string { + if username := strings.TrimSpace(result.Source.Username); username != "" { + return username + } + if len(result.Source.Articles) > 0 { + if title := strings.TrimSpace(result.Source.Articles[0].Title); title != "" { + return title + } + } + return firstNonEmpty(result.Profile.ID, result.Guide.ID, result.ID) +} + +func styleGuideDescription(result authorstyleapp.AnalyzeResult) string { + if len(result.Source.Articles) == 0 { + return "" + } + titles := make([]string, 0, len(result.Source.Articles)) + for _, article := range result.Source.Articles { + if title := strings.TrimSpace(article.Title); title != "" { + titles = append(titles, title) + } + if len(titles) == 3 { + break + } + } + return strings.Join(titles, " / ") +} + func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionResponse { var question *articleQuestionJSON if result.NextQuestion != nil { @@ -1182,6 +1393,83 @@ func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionRespons } } +func toBriefSessionSummaryResponses(sessions []briefdomain.ArticleBriefSession, briefs map[string]briefdomain.ArticleBrief) []briefSessionSummaryResponse { + items := make([]briefSessionSummaryResponse, 0, len(sessions)) + for _, session := range sessions { + articleBrief, briefAvailable := briefs[session.ID] + items = append(items, toBriefSessionSummaryResponse(session, articleBrief, briefAvailable)) + } + return items +} + +func toBriefSessionSummaryResponse(session briefdomain.ArticleBriefSession, articleBrief briefdomain.ArticleBrief, briefAvailable bool) briefSessionSummaryResponse { + return briefSessionSummaryResponse{ + SessionID: session.ID, + StyleProfileID: session.StyleProfileID, + PersonaID: session.PersonaID, + OutputFormatID: session.OutputFormatID, + ParentSessionID: session.ParentSessionID, + Phase: string(session.Phase), + Completed: session.Completed, + AnswerCount: len(session.Answers), + QuestionCount: len(session.Questions), + BriefAvailable: briefAvailable, + Title: briefTitle(session.ID, articleBrief), + } +} + +func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) { + sort.SliceStable(sessions, func(i, j int) bool { + if sessions[i].Completed != sessions[j].Completed { + return sessions[i].Completed + } + return sessions[i].ID < sessions[j].ID + }) +} + +func listBriefArtifactResponses(briefs map[string]briefdomain.ArticleBrief) []briefArtifactResponse { + sessionIDs := make([]string, 0, len(briefs)) + for sessionID := range briefs { + sessionIDs = append(sessionIDs, sessionID) + } + sort.Strings(sessionIDs) + items := make([]briefArtifactResponse, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + session, sessionOK := workflowStore.GetSession(sessionID) + items = append(items, toBriefArtifactResponse(sessionID, briefs[sessionID], session, sessionOK)) + } + return items +} + +func toBriefArtifactResponse(sessionID string, articleBrief briefdomain.ArticleBrief, session briefdomain.ArticleBriefSession, hasSession bool) briefArtifactResponse { + answerCount := 0 + parentSessionID := "" + if hasSession { + answerCount = len(session.Answers) + parentSessionID = session.ParentSessionID + } + return briefArtifactResponse{ + SessionID: sessionID, + StyleProfileID: articleBrief.StyleProfileID, + PersonaID: articleBrief.PersonaID, + OutputFormatID: articleBrief.OutputFormatID, + ParentSessionID: parentSessionID, + Title: briefTitle(sessionID, articleBrief), + Description: briefDescription(articleBrief), + AnswerCount: answerCount, + DeepDiveCount: len(articleBrief.DeepDives), + Brief: articleBrief, + } +} + +func briefTitle(sessionID string, articleBrief briefdomain.ArticleBrief) string { + return firstNonEmpty(articleBrief.Theme, articleBrief.OpeningEpisode, articleBrief.Reader, sessionID) +} + +func briefDescription(articleBrief briefdomain.ArticleBrief) string { + return firstNonEmpty(articleBrief.ExpectedReaderAction, articleBrief.MustInclude, articleBrief.PersonalContext) +} + func toArticleQuestionJSONList(questions []briefdomain.ArticleQuestion) []articleQuestionJSON { result := make([]articleQuestionJSON, 0, len(questions)) for _, question := range questions { @@ -1201,6 +1489,13 @@ func toArticleQuestionJSON(question briefdomain.ArticleQuestion) articleQuestion } } +func formatOptionalTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + func newID(prefix string) string { var bytes [6]byte if _, err := rand.Read(bytes[:]); err != nil { diff --git a/internal/handlers/workflow_history_test.go b/internal/handlers/workflow_history_test.go new file mode 100644 index 0000000..43497bb --- /dev/null +++ b/internal/handlers/workflow_history_test.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief" + "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory" +) + +func TestWorkflowHistoryHandlersReturnSavedArtifacts(t *testing.T) { + style := setupWorkflowStyle(t) + completed := sessionWithFixedAnswers(t, "session-completed-history", style.Profile.ID) + completed.MarkDeepDiveSkipped() + brief, err := completed.Complete() + if err != nil { + t.Fatalf("complete session: %v", err) + } + if err := workflowStore.SaveSession(completed); err != nil { + t.Fatalf("save completed session: %v", err) + } + if err := workflowStore.SaveBrief(completed.ID, brief); err != nil { + t.Fatalf("save brief: %v", err) + } + open, err := briefdomain.NewArticleBriefSession("session-open-history", style.Profile.ID) + if err != nil { + t.Fatalf("new open session: %v", err) + } + if err := workflowStore.SaveSession(open); err != nil { + t.Fatalf("save open session: %v", err) + } + + t.Run("author styles", func(t *testing.T) { + response := httptest.NewRecorder() + ListAuthorStylesHandler(response, httptest.NewRequest(http.MethodGet, "/api/author-style", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload authorStyleListResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.StyleGuides) != 1 || payload.StyleGuides[0].ProfileID != style.Profile.ID || payload.StyleGuides[0].GuideMarkdown == "" { + t.Fatalf("unexpected style guide list: %#v", payload) + } + }) + + t.Run("sessions", func(t *testing.T) { + response := httptest.NewRecorder() + ListBriefSessionsHandler(response, httptest.NewRequest(http.MethodGet, "/api/brief-sessions", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload briefSessionListResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Sessions) != 2 { + t.Fatalf("sessions = %d, want 2: %#v", len(payload.Sessions), payload) + } + var completedSummary briefSessionSummaryResponse + for _, item := range payload.Sessions { + if item.SessionID == completed.ID { + completedSummary = item + } + } + if !completedSummary.Completed || !completedSummary.BriefAvailable || completedSummary.Title != brief.Theme { + t.Fatalf("unexpected completed session summary: %#v", completedSummary) + } + }) + + t.Run("briefs", func(t *testing.T) { + response := httptest.NewRecorder() + ListBriefArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/briefs", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload briefArtifactListResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Briefs) != 1 || payload.Briefs[0].SessionID != completed.ID || payload.Briefs[0].Brief.Theme != brief.Theme { + t.Fatalf("unexpected brief list: %#v", payload) + } + }) + + t.Run("workflow artifacts", func(t *testing.T) { + response := httptest.NewRecorder() + ListWorkflowArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/workflow/artifacts", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload workflowArtifactsResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.StyleGuides) != 1 || len(payload.Sessions) != 2 || len(payload.Briefs) != 1 { + t.Fatalf("unexpected workflow artifacts: %#v", payload) + } + }) +} + +func TestGetBriefArtifactHandler(t *testing.T) { + style := setupWorkflowStyle(t) + session := sessionWithFixedAnswers(t, "session-brief-detail", style.Profile.ID) + session.MarkDeepDiveSkipped() + brief, err := session.Complete() + if err != nil { + t.Fatalf("complete session: %v", err) + } + if err := workflowStore.SaveSession(session); err != nil { + t.Fatalf("save session: %v", err) + } + if err := workflowStore.SaveBrief(session.ID, brief); err != nil { + t.Fatalf("save brief: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/api/briefs/session-brief-detail", nil) + request = mux.SetURLVars(request, map[string]string{"id": session.ID}) + response := httptest.NewRecorder() + + GetBriefArtifactHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload briefArtifactResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.SessionID != session.ID || payload.Title != brief.Theme || payload.AnswerCount != len(session.Answers) { + t.Fatalf("unexpected brief artifact: %#v", payload) + } +} + +func TestGetBriefArtifactHandlerNotFound(t *testing.T) { + workflowStore = memory.NewWorkflowStore() + request := httptest.NewRequest(http.MethodGet, "/api/briefs/missing", nil) + request = mux.SetURLVars(request, map[string]string{"id": "missing"}) + response := httptest.NewRecorder() + + GetBriefArtifactHandler(response, request) + + assertErrorResponse(t, response, http.StatusNotFound, "BRIEF_NOT_FOUND") +} diff --git a/internal/infrastructure/repository/memory/workflow.go b/internal/infrastructure/repository/memory/workflow.go index d06f87d..e59c36d 100644 --- a/internal/infrastructure/repository/memory/workflow.go +++ b/internal/infrastructure/repository/memory/workflow.go @@ -90,6 +90,17 @@ func (s *WorkflowStore) GetAuthorStyle(id string) (authorstyle.AnalyzeResult, bo return authorstyle.AnalyzeResult{}, false } +// ListAuthorStyles returns all stored author style analyses. +func (s *WorkflowStore) ListAuthorStyles() ([]authorstyle.AnalyzeResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + results := make([]authorstyle.AnalyzeResult, 0, len(s.authorStyles)) + for _, result := range s.authorStyles { + results = append(results, result) + } + return results, nil +} + // SaveSession stores a brief interview session. func (s *WorkflowStore) SaveSession(session briefdomain.ArticleBriefSession) error { if session.ID == "" { @@ -109,6 +120,17 @@ func (s *WorkflowStore) GetSession(id string) (briefdomain.ArticleBriefSession, return session, ok } +// ListSessions returns all stored brief interview sessions. +func (s *WorkflowStore) ListSessions() ([]briefdomain.ArticleBriefSession, error) { + s.mu.RLock() + defer s.mu.RUnlock() + sessions := make([]briefdomain.ArticleBriefSession, 0, len(s.sessions)) + for _, session := range s.sessions { + sessions = append(sessions, session) + } + return sessions, nil +} + // SaveBrief stores the completed brief for a session. func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBrief) error { if sessionID == "" { @@ -131,6 +153,17 @@ func (s *WorkflowStore) GetBrief(sessionID string) (briefdomain.ArticleBrief, bo return brief, ok } +// ListBriefs returns all stored completed briefs by session id. +func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error) { + s.mu.RLock() + defer s.mu.RUnlock() + briefs := make(map[string]briefdomain.ArticleBrief, len(s.briefs)) + for sessionID, brief := range s.briefs { + briefs[sessionID] = brief + } + return briefs, nil +} + // GetProfileAndGuide returns style assets by profile, guide, or analysis ID. func (s *WorkflowStore) GetProfileAndGuide(id string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) { result, ok := s.GetAuthorStyle(id) diff --git a/internal/infrastructure/repository/sqlite/workflow.go b/internal/infrastructure/repository/sqlite/workflow.go index e20005c..b74912e 100644 --- a/internal/infrastructure/repository/sqlite/workflow.go +++ b/internal/infrastructure/repository/sqlite/workflow.go @@ -308,6 +308,41 @@ LIMIT 1`, id, id, id, id).Scan(&result.ID, &sourceJSON, &profileJSON, &guideJSON return result, true } +// ListAuthorStyles returns all stored author style analyses in newest-first order. +func (s *WorkflowStore) ListAuthorStyles() ([]authorstyleapp.AnalyzeResult, error) { + rows, err := s.db.Query(` +SELECT id, source_json, profile_json, guide_json, article_count, created_at +FROM author_style_results +ORDER BY created_at DESC, id`) + if err != nil { + return nil, fmt.Errorf("list author styles: %w", err) + } + defer rows.Close() + var results []authorstyleapp.AnalyzeResult + for rows.Next() { + var result authorstyleapp.AnalyzeResult + var sourceJSON, profileJSON, guideJSON, createdAt string + if err := rows.Scan(&result.ID, &sourceJSON, &profileJSON, &guideJSON, &result.ArticleCount, &createdAt); err != nil { + return nil, fmt.Errorf("scan author style: %w", err) + } + if err := unmarshalString(sourceJSON, &result.Source); err != nil { + return nil, fmt.Errorf("decode author source %q: %w", result.ID, err) + } + if err := unmarshalString(profileJSON, &result.Profile); err != nil { + return nil, fmt.Errorf("decode author profile %q: %w", result.ID, err) + } + if err := unmarshalString(guideJSON, &result.Guide); err != nil { + return nil, fmt.Errorf("decode writing style guide %q: %w", result.ID, err) + } + result.CreatedAt = parseTime(createdAt) + results = append(results, result) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate author styles: %w", err) + } + return results, nil +} + // GetProfileAndGuide returns style assets by profile, guide, or analysis ID. func (s *WorkflowStore) GetProfileAndGuide(id string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) { result, ok := s.GetAuthorStyle(id) @@ -413,6 +448,38 @@ WHERE id = ?`, id).Scan(&session.ID, &session.StyleProfileID, &session.PersonaID return session, true } +// ListSessions returns all stored brief interview sessions in newest-first order. +func (s *WorkflowStore) ListSessions() ([]briefdomain.ArticleBriefSession, error) { + rows, err := s.db.Query(` +SELECT id +FROM brief_sessions +ORDER BY updated_at DESC, id`) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan session id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate session ids: %w", err) + } + sessions := make([]briefdomain.ArticleBriefSession, 0, len(ids)) + for _, id := range ids { + session, ok := s.GetSession(id) + if !ok { + return nil, fmt.Errorf("session %q disappeared while listing", id) + } + sessions = append(sessions, session) + } + return sessions, nil +} + // SaveBrief stores the completed brief for a session. func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBrief) error { if sessionID == "" { @@ -456,6 +523,34 @@ func (s *WorkflowStore) GetBrief(sessionID string) (briefdomain.ArticleBrief, bo return brief, true } +// ListBriefs returns all stored completed briefs keyed by session id. +func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error) { + rows, err := s.db.Query(` +SELECT session_id, brief_json +FROM briefs +ORDER BY updated_at DESC, session_id`) + if err != nil { + return nil, fmt.Errorf("list briefs: %w", err) + } + defer rows.Close() + briefs := map[string]briefdomain.ArticleBrief{} + for rows.Next() { + var sessionID, briefJSON string + var brief briefdomain.ArticleBrief + if err := rows.Scan(&sessionID, &briefJSON); err != nil { + return nil, fmt.Errorf("scan brief: %w", err) + } + if err := unmarshalString(briefJSON, &brief); err != nil { + return nil, fmt.Errorf("decode brief %q: %w", sessionID, err) + } + briefs[sessionID] = brief + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate briefs: %w", err) + } + return briefs, nil +} + // SaveProject stores a project aggregate. func (s *WorkflowStore) SaveProject(project ProjectRecord) error { if strings.TrimSpace(project.ID) == "" { @@ -499,6 +594,34 @@ func (s *WorkflowStore) GetProject(id string) (ProjectRecord, bool) { return project, true } +// ListProjects returns projects in most-recently-updated order. +func (s *WorkflowStore) ListProjects() ([]ProjectRecord, error) { + rows, err := s.db.Query(` +SELECT id, name, created_at, updated_at, metadata_json +FROM projects +ORDER BY updated_at DESC, id`) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + defer rows.Close() + var records []ProjectRecord + for rows.Next() { + var record ProjectRecord + var createdAt, updatedAt, metadataJSON string + if err := rows.Scan(&record.ID, &record.Name, &createdAt, &updatedAt, &metadataJSON); err != nil { + return nil, fmt.Errorf("scan project: %w", err) + } + record.CreatedAt = parseTime(createdAt) + record.UpdatedAt = parseTime(updatedAt) + _ = unmarshalString(metadataJSON, &record.Metadata) + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate projects: %w", err) + } + return records, nil +} + // SaveArticle stores an article aggregate. func (s *WorkflowStore) SaveArticle(article ArticleRecord) error { if strings.TrimSpace(article.ID) == "" { @@ -560,6 +683,39 @@ FROM articles WHERE id = ?`, id).Scan(&article.ID, &projectID, &article.PersonaI return article, true } +// ListArticlesByProject returns articles for a project in most-recently-updated order. +func (s *WorkflowStore) ListArticlesByProject(projectID string) ([]ArticleRecord, error) { + rows, err := s.db.Query(` +SELECT id +FROM articles +WHERE project_id = ? +ORDER BY updated_at DESC, id`, projectID) + if err != nil { + return nil, fmt.Errorf("list project articles: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan article id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate article ids: %w", err) + } + records := make([]ArticleRecord, 0, len(ids)) + for _, id := range ids { + record, ok := s.GetArticle(id) + if !ok { + return nil, fmt.Errorf("article %q disappeared while listing", id) + } + records = append(records, record) + } + return records, nil +} + // SaveSourceSnapshot stores source selector and fetch snapshots. func (s *WorkflowStore) SaveSourceSnapshot(snapshot SourceSnapshotRecord) error { if strings.TrimSpace(snapshot.ID) == "" { diff --git a/static/css/style.css b/static/css/style.css index 2220eb5..c8086af 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -179,6 +179,48 @@ body { color: var(--text); } +.history-config { + margin: 4px 0 14px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfe; +} + +.history-config .section-heading { + margin-top: 0; +} + +.history-picker-grid { + display: grid; + grid-template-columns: minmax(160px, 0.65fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.history-status { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--muted); + background: var(--surface); + font-size: 14px; +} + +.history-status.loading { + border-style: dashed; +} + +.history-status.empty { + background: #f8fafc; +} + +.history-status.warning { + border-color: #fde68a; + color: var(--warning); + background: var(--warning-bg); +} + .section-heading { display: flex; align-items: center; @@ -333,6 +375,86 @@ pre { padding: 12px; } +.artifact-card { + display: grid; + gap: 12px; + margin: 10px 0 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.artifact-card.empty { + color: var(--muted); + background: #f8fafc; + border-style: dashed; +} + +.artifact-card-header { + display: grid; + gap: 8px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.artifact-card-header strong { + line-height: 1.35; +} + +.artifact-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.artifact-meta span { + max-width: 100%; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: #f8fafc; + overflow-wrap: anywhere; +} + +.artifact-section { + display: grid; + gap: 6px; +} + +.artifact-section h4 { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.artifact-section ul { + display: grid; + gap: 6px; + margin: 0; + padding-left: 18px; +} + +.artifact-section li { + overflow-wrap: anywhere; +} + +.artifact-raw { + color: var(--muted); + font-size: 14px; +} + +.artifact-raw summary { + cursor: pointer; + font-weight: 650; +} + +.artifact-raw pre { + margin-top: 8px; +} + .question-log { display: grid; gap: 14px; @@ -581,6 +703,7 @@ pre { } .config-grid, + .history-picker-grid, .result-grid { grid-template-columns: 1fr; } diff --git a/static/history_ui_test.go b/static/history_ui_test.go new file mode 100644 index 0000000..76f87c5 --- /dev/null +++ b/static/history_ui_test.go @@ -0,0 +1,57 @@ +package static_test + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" +) + +func TestHistoryUIContract(t *testing.T) { + index, err := os.ReadFile("index.html") + if err != nil { + t.Fatalf("read index: %v", err) + } + script, err := os.ReadFile("js/script.js") + if err != nil { + t.Fatalf("read script: %v", err) + } + document, err := goquery.NewDocumentFromReader(bytes.NewReader(index)) + if err != nil { + t.Fatalf("parse index: %v", err) + } + + for _, selector := range []string{ + "#history-persona-select", + "#history-style-select", + "#history-session-select", + "#refresh-history-btn", + "#open-history-btn", + "#clear-history-selection-btn", + "#history-status", + "#style-guide-card", + "#brief-card", + } { + if document.Find(selector).Length() != 1 { + t.Fatalf("selector %s count = %d, want 1", selector, document.Find(selector).Length()) + } + } + + source := string(script) + for _, want := range []string{ + "const historyEndpoint = '/api/workflow/artifacts'", + "loadWorkflowHistory", + "normalizeWorkflowHistory", + "renderStyleGuideCard", + "renderBriefCard", + "requestJSON(`${historyEndpoint}?", + "/api/author-style/", + "/api/brief-sessions/", + } { + if !strings.Contains(source, want) { + t.Fatalf("script missing %q", want) + } + } +} diff --git a/static/index.html b/static/index.html index d19f325..882d4e7 100644 --- a/static/index.html +++ b/static/index.html @@ -77,6 +77,32 @@

保存方式

+
+
+

履歴から再開

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+

取材質問

@@ -132,7 +158,11 @@

文体分析 / プリセット

文体ガイド

-

+          
+
+ Markdownを表示 +

+          
@@ -161,7 +191,11 @@

記事取材

diff --git a/static/js/script.js b/static/js/script.js index bc7e6e3..427227e 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -1,5 +1,6 @@ document.addEventListener('DOMContentLoaded', () => { const configStorageKey = 'note-maker-config-v1'; + const historyEndpoint = '/api/workflow/artifacts'; const legacyTemplateQuestionIds = new Set([ 'theme', 'opening_episode', @@ -48,6 +49,13 @@ document.addEventListener('DOMContentLoaded', () => { templateLoading: false, templateError: '', templateRequestId: 0, + historyStyles: [], + historySessions: [], + historyLoading: false, + historyError: '', + historyRequestId: 0, + selectedHistoryStyle: null, + selectedHistorySession: null, storageConfig: null, questionTextById: {}, lastSubmittedAnswer: '', @@ -69,6 +77,13 @@ document.addEventListener('DOMContentLoaded', () => { storagePath: document.getElementById('storage-path'), saveStorage: document.getElementById('save-storage-btn'), storageSummary: document.getElementById('storage-summary'), + historyPersonaSelect: document.getElementById('history-persona-select'), + historyStyleSelect: document.getElementById('history-style-select'), + historySessionSelect: document.getElementById('history-session-select'), + refreshHistory: document.getElementById('refresh-history-btn'), + openHistory: document.getElementById('open-history-btn'), + clearHistorySelection: document.getElementById('clear-history-selection-btn'), + historyStatus: document.getElementById('history-status'), questionConfigList: document.getElementById('question-config-list'), addQuestion: document.getElementById('add-question-btn'), resetQuestions: document.getElementById('reset-questions-btn'), @@ -80,6 +95,7 @@ document.addEventListener('DOMContentLoaded', () => { profileId: document.getElementById('profile-id'), guideId: document.getElementById('guide-id'), articleCount: document.getElementById('article-count'), + styleGuideCard: document.getElementById('style-guide-card'), guidePreview: document.getElementById('guide-preview'), startInterview: document.getElementById('start-interview-btn'), interviewArea: document.getElementById('interview-area'), @@ -89,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => { cancelAnswer: document.getElementById('cancel-answer-btn'), skipDeepDive: document.getElementById('skip-deep-dive-btn'), briefResult: document.getElementById('brief-result'), + briefCard: document.getElementById('brief-card'), briefPreview: document.getElementById('brief-preview'), generateDraft: document.getElementById('generate-draft-btn'), cancelDraft: document.getElementById('cancel-draft-btn'), @@ -124,6 +141,12 @@ document.addEventListener('DOMContentLoaded', () => { el.verifyModel.addEventListener('change', saveModelConfig); el.storageDriver.addEventListener('change', onStorageDriverChange); el.saveStorage.addEventListener('click', saveStorageConfig); + el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory); + el.historyStyleSelect.addEventListener('change', selectHistoryStyle); + el.historySessionSelect.addEventListener('change', selectHistorySession); + el.refreshHistory.addEventListener('click', loadWorkflowHistory); + el.openHistory.addEventListener('click', openSelectedHistory); + el.clearHistorySelection.addEventListener('click', clearHistorySelection); el.addQuestion.addEventListener('click', addQuestion); el.resetQuestions.addEventListener('click', resetQuestions); el.analyzeStyle.addEventListener('click', analyzeStyle); @@ -159,9 +182,11 @@ document.addEventListener('DOMContentLoaded', () => { state.formats = formats; populatePersonaSelect(); populateFormatSelect(); + populateHistoryPersonaSelect(); applyPersonaDefaults(false); renderModeSummary(); loadQuestionTemplate(); + loadWorkflowHistory(); } catch (error) { showError(`書き分け設定の取得に失敗しました: ${error.message}`); } @@ -235,6 +260,7 @@ document.addEventListener('DOMContentLoaded', () => { el.guideId.textContent = data.guide_id; el.articleCount.textContent = String(data.article_count); el.guidePreview.textContent = data.guide_markdown; + renderStyleGuideCard(data); el.styleResult.classList.remove('hidden'); el.startInterview.disabled = false; } @@ -266,6 +292,7 @@ document.addEventListener('DOMContentLoaded', () => { rememberQuestion(data.next_question); el.interviewArea.classList.remove('hidden'); el.briefResult.classList.add('hidden'); + renderBriefCard(null); el.generateDraft.disabled = true; renderTranscript(data); } catch (error) { @@ -361,6 +388,7 @@ document.addEventListener('DOMContentLoaded', () => { state.completedBrief = data.brief; state.nextQuestion = null; el.briefPreview.textContent = JSON.stringify(data.brief, null, 2); + renderBriefCard(data.brief); el.briefResult.classList.remove('hidden'); el.generateDraft.disabled = false; el.skipDeepDive.classList.add('hidden'); @@ -369,6 +397,7 @@ document.addEventListener('DOMContentLoaded', () => { state.completedBrief = null; state.nextQuestion = data.next_question; el.briefResult.classList.add('hidden'); + renderBriefCard(null); el.generateDraft.disabled = true; el.skipDeepDive.classList.toggle('hidden', data.next_question?.flow_type !== 'deep_dive_follow_up'); } @@ -740,6 +769,20 @@ document.addEventListener('DOMContentLoaded', () => { }); } + function populateHistoryPersonaSelect() { + el.historyPersonaSelect.innerHTML = ''; + state.personas.forEach((persona) => { + const option = document.createElement('option'); + option.value = persona.id; + option.textContent = persona.display_name; + option.selected = persona.id === currentPersonaId(); + el.historyPersonaSelect.appendChild(option); + }); + if (!el.historyPersonaSelect.value && state.personas[0]) { + el.historyPersonaSelect.value = state.personas[0].id; + } + } + function onPersonaChange() { config.mode.persona = currentPersonaId(); applyPersonaDefaults(true); @@ -747,6 +790,8 @@ document.addEventListener('DOMContentLoaded', () => { saveConfig(); renderModeSummary(); loadQuestionTemplate(); + syncHistoryPersonaToCurrentMode(); + loadWorkflowHistory(); } function onFormatChange() { @@ -755,8 +800,390 @@ document.addEventListener('DOMContentLoaded', () => { saveConfig(); renderModeSummary(); loadQuestionTemplate(); + loadWorkflowHistory(); + } + + function syncHistoryPersonaToCurrentMode() { + if (el.historyPersonaSelect.value !== currentPersonaId()) { + el.historyPersonaSelect.value = currentPersonaId(); + } + } + + async function loadWorkflowHistory() { + const personaId = el.historyPersonaSelect.value || currentPersonaId(); + const formatId = currentFormatId(); + const requestId = state.historyRequestId + 1; + state.historyRequestId = requestId; + state.historyLoading = true; + state.historyError = ''; + state.selectedHistoryStyle = null; + state.selectedHistorySession = null; + renderHistoryPicker(); + + try { + const data = await fetchWorkflowHistoryIndex({ personaId, formatId }); + if (requestId !== state.historyRequestId) { + return; + } + const normalized = normalizeWorkflowHistory(data, personaId, formatId); + state.historyStyles = normalized.styles; + state.historySessions = normalized.sessions; + } catch (error) { + if (requestId !== state.historyRequestId) { + return; + } + state.historyStyles = []; + state.historySessions = []; + state.historyError = historyErrorMessage(error); + } finally { + if (requestId === state.historyRequestId) { + state.historyLoading = false; + renderHistoryPicker(); + } + } + } + + async function fetchWorkflowHistoryIndex({ personaId, formatId }) { + const params = new URLSearchParams(); + if (personaId) { + params.set('persona_id', personaId); + } + if (formatId) { + params.set('format_id', formatId); + } + return requestJSON(`${historyEndpoint}?${params.toString()}`); + } + + function renderHistoryPicker() { + renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択'); + renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択'); + + el.historyStyleSelect.disabled = state.historyLoading || !state.historyStyles.length; + el.historySessionSelect.disabled = state.historyLoading || !state.historySessions.length; + el.openHistory.disabled = state.historyLoading || !historySelectionReady(); + + if (state.historyLoading) { + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '保存済みの文体ガイドと取材セッションを読み込んでいます...'; + return; + } + if (state.historyError) { + el.historyStatus.className = 'history-status warning'; + el.historyStatus.textContent = state.historyError; + return; + } + if (!state.historyStyles.length && !state.historySessions.length) { + el.historyStatus.className = 'history-status empty'; + el.historyStatus.textContent = 'この書き手と出力先の保存済み履歴はまだありません。'; + return; + } + const styleCount = `${state.historyStyles.length}件の文体ガイド`; + const sessionCount = `${state.historySessions.length}件の取材セッション`; + el.historyStatus.className = 'history-status'; + el.historyStatus.textContent = `${styleCount} / ${sessionCount} を選択できます。`; + } + + function renderHistoryOptions(select, items, placeholder) { + const selected = select.value; + select.innerHTML = ''; + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = placeholder; + select.appendChild(empty); + items.forEach((item) => { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = historyOptionLabel(item); + select.appendChild(option); + }); + if (items.some((item) => item.id === selected)) { + select.value = selected; + } + } + + function historyOptionLabel(item) { + const title = item.title || item.theme || item.label || item.id; + const status = item.completed === true ? '完了' : item.phase || ''; + const updatedAt = formatDateTime(item.updatedAt || item.createdAt); + return [title, status, updatedAt].filter(Boolean).join(' / '); + } + + function historySelectionReady() { + return Boolean(el.historyStyleSelect.value || el.historySessionSelect.value); + } + + function historyErrorMessage(error) { + const message = error.message || ''; + if (message.includes('HTTP 404')) { + return '履歴APIはまだ接続されていません。バックエンド実装後にここへ保存済み履歴が表示されます。'; + } + return `履歴の取得に失敗しました: ${message}`; + } + + async function selectHistoryStyle() { + state.selectedHistoryStyle = findHistoryStyle(el.historyStyleSelect.value); + el.openHistory.disabled = !historySelectionReady(); + if (!state.selectedHistoryStyle) { + return; + } + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '保存済み文体ガイドを確認しています...'; + try { + const detail = await loadHistoryStyleDetail(state.selectedHistoryStyle); + state.selectedHistoryStyle = detail; + renderStyleGuideCard(detail); + el.guidePreview.textContent = styleGuideMarkdown(detail); + el.styleResult.classList.remove('hidden'); + renderHistoryPicker(); + } catch (error) { + el.historyStatus.className = 'history-status warning'; + el.historyStatus.textContent = `文体ガイドを開けませんでした: ${error.message}`; + } + } + + async function selectHistorySession() { + state.selectedHistorySession = findHistorySession(el.historySessionSelect.value); + el.openHistory.disabled = !historySelectionReady(); + if (!state.selectedHistorySession) { + return; + } + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '保存済み取材セッションを確認しています...'; + try { + const detail = await loadHistorySessionDetail(state.selectedHistorySession); + state.selectedHistorySession = detail; + if (detail.brief) { + renderBriefCard(detail.brief); + el.briefPreview.textContent = JSON.stringify(detail.brief, null, 2); + el.briefResult.classList.remove('hidden'); + } + renderHistoryPicker(); + } catch (error) { + el.historyStatus.className = 'history-status warning'; + el.historyStatus.textContent = `取材セッションを開けませんでした: ${error.message}`; + } + } + + async function openSelectedHistory() { + clearError(); + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '選択した履歴を開いています...'; + try { + const style = el.historyStyleSelect.value + ? await loadHistoryStyleDetail(state.selectedHistoryStyle || findHistoryStyle(el.historyStyleSelect.value)) + : null; + const session = el.historySessionSelect.value + ? await loadHistorySessionDetail(state.selectedHistorySession || findHistorySession(el.historySessionSelect.value)) + : null; + const styleForSession = !style && session?.styleProfileId + ? await loadHistoryStyleDetail({ id: session.styleProfileId }) + : style; + + if (styleForSession) { + applyHistoryStyle(styleForSession); + } + if (session) { + await applyHistorySession(session); + } + if (!styleForSession && !session) { + showError('開く履歴を選択してください'); + return; + } + el.historyStatus.className = 'history-status'; + el.historyStatus.textContent = '選択した履歴を現在の作業状態に反映しました。'; + } catch (error) { + el.historyStatus.className = 'history-status warning'; + el.historyStatus.textContent = `履歴を開けませんでした: ${error.message}`; + } + } + + function clearHistorySelection() { + el.historyStyleSelect.value = ''; + el.historySessionSelect.value = ''; + state.selectedHistoryStyle = null; + state.selectedHistorySession = null; + renderHistoryPicker(); + } + + async function loadHistoryStyleDetail(item) { + if (!item) { + return null; + } + if (styleGuideMarkdown(item)) { + return item; + } + const data = await requestJSON(`/api/author-style/${encodeURIComponent(item.id)}`); + return normalizeHistoryStyle({ ...item, ...data }); + } + + async function loadHistorySessionDetail(item) { + if (!item) { + return null; + } + if (item.answers?.length || item.brief || item.nextQuestion) { + return item; + } + const data = await requestJSON(`/api/brief-sessions/${encodeURIComponent(item.id)}`); + return normalizeHistorySession({ ...item, ...data }); } + function applyHistoryStyle(item) { + const data = normalizeHistoryStyle(item); + state.profileId = data.profileId || data.id; + el.profileId.textContent = state.profileId; + el.guideId.textContent = data.guideId || ''; + el.articleCount.textContent = data.articleCount === undefined ? '' : String(data.articleCount); + el.guidePreview.textContent = styleGuideMarkdown(data); + renderStyleGuideCard(data); + el.styleResult.classList.remove('hidden'); + el.startInterview.disabled = !state.profileId; + } + + async function applyHistorySession(item) { + const data = normalizeHistorySession(item); + if (data.personaId && state.personas.some((persona) => persona.id === data.personaId)) { + el.personaSelect.value = data.personaId; + config.mode.persona = data.personaId; + } + if (data.outputFormatId && state.formats.some((format) => format.id === data.outputFormatId)) { + el.formatSelect.value = data.outputFormatId; + config.mode.format = data.outputFormatId; + } + saveConfig(); + renderModeSummary(); + applyStyleSourceDefault(true); + await loadQuestionTemplate(); + state.sessionId = data.id; + state.parentSessionId = data.parentSessionId || ''; + state.profileId = data.styleProfileId || state.profileId; + state.answers = data.answers || []; + state.nextQuestion = data.nextQuestion || null; + state.completedBrief = data.completed ? data.brief : null; + rememberQuestions(data.questions || state.templateQuestions); + rememberQuestion(data.nextQuestion); + el.interviewArea.classList.remove('hidden'); + renderTranscript({ + answers: state.answers, + next_question: state.nextQuestion, + completed: data.completed, + }); + if (data.completed && data.brief) { + el.briefPreview.textContent = JSON.stringify(data.brief, null, 2); + renderBriefCard(data.brief); + el.briefResult.classList.remove('hidden'); + el.generateDraft.disabled = !state.profileId; + el.skipDeepDive.classList.add('hidden'); + } else { + renderBriefCard(null); + el.briefResult.classList.add('hidden'); + el.generateDraft.disabled = true; + el.skipDeepDive.classList.toggle('hidden', data.nextQuestion?.flow_type !== 'deep_dive_follow_up'); + } + updateSectionControls(); + } + + function normalizeWorkflowHistory(data, personaId, formatId) { + const source = data || {}; + const styleValues = arrayFrom(source.style_guides || source.styleGuides || source.styles || source.author_styles || source.authorStyles || source.profiles); + const sessionValues = [ + ...arrayFrom(source.sessions || source.brief_sessions || source.briefSessions || source.items || (Array.isArray(source) ? source : [])), + ...arrayFrom(source.briefs || source.Briefs), + ]; + return { + styles: styleValues.map(normalizeHistoryStyle) + .filter((item) => item.id) + .filter((item) => historyItemMatches(item, personaId, formatId)), + sessions: uniqueHistoryItems(sessionValues.map(normalizeHistorySession) + .filter((item) => item.id) + .filter((item) => historyItemMatches(item, personaId, formatId))), + }; + } + + function normalizeHistoryStyle(item = {}) { + const profile = item.profile || item.Profile || {}; + const guide = item.guide || item.Guide || {}; + return { + ...item, + id: String(item.profile_id || item.profileId || item.style_profile_id || item.styleProfileId || profile.id || profile.ID || item.id || item.ID || '').trim(), + resultId: item.id || item.ID || '', + profileId: item.profile_id || item.profileId || item.style_profile_id || item.styleProfileId || profile.id || profile.ID || '', + guideId: item.guide_id || item.guideId || guide.id || guide.ID || '', + title: item.title || item.name || item.label || item.display_name || item.displayName || profile.name || profile.Name || '', + personaId: item.persona_id || item.personaId || profile.persona_id || profile.PersonaID || '', + outputFormatId: item.output_format_id || item.outputFormatId || profile.output_format_id || profile.OutputFormatID || '', + articleCount: item.article_count ?? item.articleCount ?? item.source?.article_count ?? item.Source?.ArticleCount, + guideMarkdown: item.guide_markdown || item.guideMarkdown || item.markdown || item.Markdown || guide.markdown || guide.Markdown || '', + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || '', + createdAt: item.created_at || item.createdAt || '', + source: item.source || item.Source || {}, + profile, + guide, + }; + } + + function normalizeHistorySession(item = {}) { + const brief = item.brief || item.Brief || null; + const title = item.title || item.name || briefField(brief, 'theme', 'Theme') || ''; + return { + ...item, + id: String(item.session_id || item.sessionId || item.id || item.ID || '').trim(), + title, + theme: briefField(brief, 'theme', 'Theme'), + styleProfileId: item.style_profile_id || item.styleProfileId || item.profile_id || item.profileId || briefField(brief, 'styleProfileId', 'StyleProfileID') || '', + personaId: item.persona_id || item.personaId || briefField(brief, 'personaId', 'PersonaID') || '', + outputFormatId: item.output_format_id || item.outputFormatId || briefField(brief, 'outputFormatId', 'OutputFormatID') || '', + parentSessionId: item.parent_session_id || item.parentSessionId || '', + phase: item.phase || item.Phase || '', + completed: item.completed ?? item.Completed ?? Boolean(brief), + brief, + answers: item.answers || item.Answers || [], + questions: normalizeQuestionList(item.questions || item.Questions || []), + nextQuestion: normalizeHistoryQuestion(item.next_question || item.nextQuestion || item.NextQuestion || null), + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || '', + createdAt: item.created_at || item.createdAt || '', + }; + } + + function normalizeHistoryQuestion(question) { + if (!question) { + return null; + } + return { + id: question.id || question.ID || '', + text: question.text || question.Text || '', + flow_type: question.flow_type || question.flowType || question.FlowType || 'main', + target_field: question.target_field || question.targetField || question.TargetField || '', + target_question_id: question.target_question_id || question.targetQuestionId || question.TargetQuestionID || '', + follow_up_index: question.follow_up_index || question.followUpIndex || question.FollowUpIndex || 0, + required: question.required ?? question.Required, + }; + } + + function historyItemMatches(item, personaId, formatId) { + return (!item.personaId || !personaId || item.personaId === personaId) + && (!item.outputFormatId || !formatId || item.outputFormatId === formatId); + } + + function findHistoryStyle(id) { + return state.historyStyles.find((item) => item.id === id) || null; + } + + function findHistorySession(id) { + return state.historySessions.find((item) => item.id === id) || null; + } + + function uniqueHistoryItems(items) { + const byId = new Map(); + items.forEach((item) => { + const existing = byId.get(item.id); + if (!existing || (!existing.brief && item.brief)) { + byId.set(item.id, item); + } + }); + return [...byId.values()]; + } + + function applyPersonaDefaults(forceFormat) { const persona = currentPersona(); if (!persona) { @@ -1148,6 +1575,180 @@ document.addEventListener('DOMContentLoaded', () => { .replaceAll("'", '''); } + function renderStyleGuideCard(data) { + el.styleGuideCard.innerHTML = ''; + const markdown = styleGuideMarkdown(data); + if (!data || !markdown) { + el.styleGuideCard.className = 'artifact-card empty'; + el.styleGuideCard.textContent = '文体ガイドはまだありません。文体分析または保存済み履歴から選択してください。'; + return; + } + const normalized = normalizeHistoryStyle(data); + el.styleGuideCard.className = 'artifact-card'; + el.styleGuideCard.appendChild(createArtifactHeader( + normalized.title || '文体ガイド', + [ + ['Profile', normalized.profileId || normalized.id], + ['Guide', normalized.guideId], + ['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)], + ], + )); + const sections = markdownSectionsForCard(markdown); + if (sections.length) { + sections.slice(0, 5).forEach((section) => { + el.styleGuideCard.appendChild(createArtifactSection(section.title, section.items)); + }); + } else { + el.styleGuideCard.appendChild(createArtifactSection('要点', compactTextLines(markdown, 6))); + } + } + + function renderBriefCard(brief) { + el.briefCard.innerHTML = ''; + if (!brief) { + el.briefCard.className = 'artifact-card empty'; + el.briefCard.textContent = '記事ブリーフはまだありません。取材完了後、または保存済みセッション選択後に表示します。'; + return; + } + el.briefCard.className = 'artifact-card'; + const theme = briefField(brief, 'theme', 'Theme') || '記事ブリーフ'; + el.briefCard.appendChild(createArtifactHeader( + theme, + [ + ['Persona', briefField(brief, 'persona_id', 'PersonaID')], + ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')], + ['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')], + ], + )); + [ + ['読者', briefField(brief, 'reader', 'Reader')], + ['冒頭の具体例', briefField(brief, 'opening_episode', 'OpeningEpisode')], + ['読後アクション', briefField(brief, 'expected_reader_action', 'ExpectedReaderAction')], + ['必ず含めること', briefField(brief, 'must_include', 'MustInclude')], + ['本人文脈', briefField(brief, 'personal_context', 'PersonalContext')], + ['含めないこと', briefField(brief, 'exclusions', 'Exclusions')], + ['構成と長さ', briefField(brief, 'target_length_structure', 'TargetLengthStructure')], + ['トーンと立場', briefField(brief, 'tone_stance', 'ToneStance')], + ].filter(([, value]) => String(value || '').trim()).forEach(([label, value]) => { + el.briefCard.appendChild(createArtifactSection(label, [value])); + }); + const customAnswers = brief.CustomAnswers || brief.custom_answers || brief.customAnswers || []; + const deepDives = brief.DeepDives || brief.deep_dives || brief.deepDives || []; + if (customAnswers.length) { + el.briefCard.appendChild(createAnswerSection('追加回答', customAnswers)); + } + if (deepDives.length) { + el.briefCard.appendChild(createAnswerSection('深掘りメモ', deepDives)); + } + } + + function createArtifactHeader(title, metaItems) { + const header = document.createElement('div'); + header.className = 'artifact-card-header'; + const titleElement = document.createElement('strong'); + titleElement.textContent = title; + const meta = document.createElement('div'); + meta.className = 'artifact-meta'; + metaItems.filter(([, value]) => value !== undefined && value !== null && String(value).trim()).forEach(([label, value]) => { + const item = document.createElement('span'); + item.textContent = `${label}: ${value}`; + meta.appendChild(item); + }); + header.append(titleElement, meta); + return header; + } + + function createArtifactSection(title, values) { + const section = document.createElement('section'); + section.className = 'artifact-section'; + const heading = document.createElement('h4'); + heading.textContent = title; + section.appendChild(heading); + const list = document.createElement('ul'); + values.filter((value) => String(value || '').trim()).forEach((value) => { + const item = document.createElement('li'); + item.textContent = String(value).trim(); + list.appendChild(item); + }); + section.appendChild(list); + return section; + } + + function createAnswerSection(title, answers) { + return createArtifactSection(title, answers.map((answer) => { + const questionId = answerValue(answer, 'question_id', 'QuestionID'); + const content = answerValue(answer, 'content', 'Content'); + const question = state.questionTextById[questionId] || questionId || '回答'; + return `${question}: ${content}`; + })); + } + + function markdownSectionsForCard(markdown) { + const sections = []; + let current = { title: '要点', items: [] }; + String(markdown || '').split('\n').forEach((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + const heading = trimmed.match(/^#{1,4}\s+(.+)$/); + if (heading) { + if (current.items.length) { + sections.push(current); + } + current = { title: heading[1].trim(), items: [] }; + return; + } + current.items.push(trimmed.replace(/^[-*]\s+/, '')); + }); + if (current.items.length) { + sections.push(current); + } + return sections.map((section) => ({ + title: section.title, + items: section.items.slice(0, 6), + })); + } + + function compactTextLines(value, limit) { + return String(value || '').split('\n').map((line) => line.trim()).filter(Boolean).slice(0, limit); + } + + function styleGuideMarkdown(data = {}) { + return data.guideMarkdown || data.guide_markdown || data.markdown || data.Markdown || data.guide?.markdown || data.guide?.Markdown || data.Guide?.Markdown || ''; + } + + function briefField(brief, snake, pascal) { + if (!brief) { + return ''; + } + return brief[snake] ?? brief[toCamelCase(snake)] ?? brief[pascal] ?? ''; + } + + function toCamelCase(value) { + return String(value || '').replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + } + + function arrayFrom(value) { + return Array.isArray(value) ? value : []; + } + + function formatDateTime(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleString('ja-JP', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } + function renderDraft(data) { const qualityGate = data.quality_gate || data.qualityGate || {}; const evaluation = data.evaluation;