diff --git a/cmd/server/main.go b/cmd/server/main.go index 0c6b261..eb1f65a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -61,7 +61,11 @@ func registerRoutes(r *mux.Router) { 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") r.HandleFunc("/api/sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST") + r.HandleFunc("/api/projects", handlers.ListProjectsHandler).Methods("GET") + r.HandleFunc("/api/projects/{id}", handlers.GetProjectHandler).Methods("GET") + r.HandleFunc("/api/articles/{id}", handlers.GetArticleHandler).Methods("GET") r.HandleFunc("/api/drafts", handlers.GenerateDraftHandler).Methods("POST") + r.HandleFunc("/api/drafts/{id}", handlers.GetDraftHandler).Methods("GET") r.HandleFunc("/api/drafts/{id}/regenerate-section", handlers.RegenerateDraftSectionHandler).Methods("POST") // ルートパスへのアクセスはindex.htmlにリダイレクト diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 24889ed..6df47bb 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -1,7 +1,11 @@ package main import ( + "io" "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/gorilla/mux" @@ -18,9 +22,15 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) { {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/author-style/style-1"}, {method: http.MethodGet, path: "/api/brief-sessions"}, + {method: http.MethodGet, path: "/api/brief-sessions/templates"}, + {method: http.MethodGet, path: "/api/brief-sessions/session-1"}, {method: http.MethodGet, path: "/api/briefs"}, {method: http.MethodGet, path: "/api/briefs/session-1"}, + {method: http.MethodGet, path: "/api/models"}, + {method: http.MethodGet, path: "/api/personas"}, + {method: http.MethodGet, path: "/api/formats"}, } { request, err := http.NewRequest(tt.method, tt.path, nil) if err != nil { @@ -32,3 +42,80 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) { } } } + +func TestRegisterRoutesServesBrowserEntryAndStaticScript(t *testing.T) { + chdir(t, "../..") + + router := mux.NewRouter() + registerRoutes(router) + server := httptest.NewServer(router) + defer server.Close() + + for _, tt := range []struct { + path string + contentType string + body []string + }{ + { + path: "/", + contentType: "text/html", + body: []string{ + `
`, + }, + }, + { + path: "/static/js/script.js", + contentType: "javascript", + body: []string{ + "const configStorageKey = 'note-maker-config-v1'", + "const historyEndpoint = '/api/workflow/artifacts'", + "function saveModelConfig()", + "function openSelectedHistory()", + "function renderBriefCard(brief)", + }, + }, + } { + t.Run(tt.path, func(t *testing.T) { + response, err := http.Get(server.URL + tt.path) + if err != nil { + t.Fatalf("GET %s: %v", tt.path, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want %d", tt.path, response.StatusCode, http.StatusOK) + } + if got := response.Header.Get("Content-Type"); got == "" || !strings.Contains(got, tt.contentType) { + t.Fatalf("GET %s Content-Type = %q, want contains %q", tt.path, got, tt.contentType) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + for _, want := range tt.body { + if !strings.Contains(string(body), want) { + t.Fatalf("GET %s body missing %q", tt.path, want) + } + } + }) + } +} + +func chdir(t *testing.T, dir string) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir %s: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) +} diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md index beef122..83edf59 100644 --- a/docs/adrs/0002-multi-persona-multi-format-extension.md +++ b/docs/adrs/0002-multi-persona-multi-format-extension.md @@ -222,6 +222,8 @@ Current implementation status as of 2026-05-03: - 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). +- The #13 follow-up has browser-adjacent contract coverage for the static HTML, script selectors, persistence hooks, workflow read routes, and artifact-card structure. It does not close #13: Playwright or equivalent real browser coverage is still required for persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and localStorage migration. Validation is recorded in [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md). +- A Phase C project/article/draft history follow-up now builds on the #26 SQLite schema: `GET /api/workflow/artifacts` includes project/article/draft summaries when SQLite is active, focused read routes expose project/article/draft details, and the history UI renders project, article, brief, current draft, draft versions, and source snapshot cards. The handler/server/static tests are green, but until the browser flow is covered by #13, the canonical completed Phase C closure claim remains separate from full browser E2E. - 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. @@ -235,8 +237,8 @@ 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. 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. +2. Keep [#13](https://github.com/terisuke/note_maker/issues/13) open after the browser-contract pass and add real browser E2E coverage for history opening, readable cards, persona/format switching, question config, edit/fork, streaming/cancel, regenerate-section, and localStorage migration. +3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 first cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, richer project/article/draft history polish, and browser E2E over the project history cards. 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 @@ -254,7 +256,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR. - 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 — 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. +- 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. Project/article/draft artifact browsing now has read APIs and history cards, but real browser E2E and remaining edit/add-persona semantics are still outside the #27/#28 first-cut closure claim. - 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/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md index 6502df0..7619390 100644 --- a/docs/implementation-plans/next-implementation-cut.md +++ b/docs/implementation-plans/next-implementation-cut.md @@ -34,10 +34,16 @@ Implemented in the current #27/#28 history/artifact cut: - 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). +Implemented in the #13 follow-up contract cut: + +- Static browser contract coverage checks the HTML entrypoint, production script, model selectors, question controls, history controls, artifact-card containers, persistence hooks, and route-table wiring. +- This is useful pre-Playwright coverage, but it is not equivalent to browser E2E. It does not exercise real DOM events, fetch stubbing, localStorage reload behavior, SSE/cancel, or the section-regeneration workflow in a browser. +- Validation — [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-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. -- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13). +- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13), still open after the contract-test cut. - 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. - Fallback and 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). @@ -50,7 +56,32 @@ 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. +- Project/article/draft artifact browsing from SQLite's normalized #26 schema now has server routes, response-shape contract coverage, frontend selectors, and readable history cards. The remaining gap is real browser E2E over those cards and broader edit/add-persona semantics, so #13/#14/#27/#28 should stay open unless the owner explicitly accepts a narrower first-cut closure. + +## Current Review Findings + +Review date: 2026-05-03 on `codex/phase-c-history-e2e`. + +Validation that passed: + +```sh +go test ./... +go test ./internal/handlers ./static +go test ./cmd/server ./internal/handlers ./static +node --check static/js/script.js +git diff --check +``` + +No blocking code-risk finding remains in the targeted suite after the parallel fixes. The remaining closure risk is coverage scope: #13 still lacks real browser E2E, and #27/#28 still contain product scope that is broader than the first cut. + +## Issue Close/Open Proposal + +| Issue | Proposal | Rationale | +|---|---|---| +| [#13](https://github.com/terisuke/note_maker/issues/13) | Keep open | Static and route contract coverage was added, but the issue asks for browser E2E. Real browser coverage still needs model config, question customisation, persona/format switching, history open, readable cards, streaming/cancel, edit/fork, regenerate-section, and localStorage migration. | +| [#14](https://github.com/terisuke/note_maker/issues/14) | Keep open | #26 gives the SQLite schema and restart-capable storage foundation, but the product still lacks full queryable project/article/draft browsing and versioned edit/history surfaces. | +| [#27](https://github.com/terisuke/note_maker/issues/27) | Keep open, or close only if the issue owner accepts the first-cut scope | Saved style-guide/session reuse is implemented, but the original issue still includes add-persona authoring UI, project/article navigation, restart semantics, and broader edit persistence. | +| [#28](https://github.com/terisuke/note_maker/issues/28) | Keep open, or close only if the issue owner accepts the first-cut scope | Readable style-guide and brief cards landed, but editable card persistence/versioning and richer project/article/draft artifacts are still outstanding. | ## Final evaluation target @@ -124,14 +155,14 @@ Use subagents with disjoint write scopes when implementation resumes: |---|---|---|---|---| | 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 | 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 | +| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | contract tests are done; close only after persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and legacy localStorage migration are covered in a browser | | 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. 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. +1. Wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming/cancel, and regenerate-section coverage goals. +2. Split the remaining Phase C product work into explicit follow-up issues before broadening implementation: add-persona authoring UI, broader edit persistence semantics, project/article/draft artifact browsing polish from the #26 SQLite schema, and real browser E2E over these history paths. 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-13-browser-contract-coverage-2026-05-03.md b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md new file mode 100644 index 0000000..2738589 --- /dev/null +++ b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md @@ -0,0 +1,59 @@ +# Issue #13 Browser Contract Coverage + +Date: 2026-05-03 +Branch: `codex/phase-c-history-e2e` + +## Scope + +Added practical browser-adjacent coverage that remains part of `go test ./...`: + +- Static HTML contract checks for model selectors, question controls, history controls, and artifact card containers. +- Static JavaScript contract checks for model config persistence, custom question add/edit/delete/reset behavior, history loading/opening flow, and readable artifact-card rendering. +- `httptest` coverage that the server route table exposes workflow read APIs and serves the browser entrypoint plus the production script. + +This validates contract shape, not user behavior in a real browser. Issue [#13](https://github.com/terisuke/note_maker/issues/13) stays open until Playwright or equivalent browser E2E covers the actual flows. + +## Why Not Playwright Yet + +The current frontend script is a single `DOMContentLoaded` closure with no importable UI functions. A Playwright suite is still the right next step, but adding it now would require a heavier test harness with stubbed API routes and browser setup that is not yet present in this repository. + +This pass keeps CI friction low by expanding Go tests first. The contract tests intentionally lock DOM IDs, event bindings, persistence calls, fetch endpoints, and card-rendering structure so a later Playwright suite has stable selectors and scenarios to target. + +## Next Playwright Path + +Recommended scenarios when browser E2E is introduced: + +1. Stub `/api/models`, `/api/personas`, `/api/formats`, `/api/brief-sessions/templates`, and `/api/workflow/artifacts`. +2. Assert all four model selectors populate from `/api/models`, save into `localStorage["note-maker-config-v1"]`, and restore on reload. +3. Add a custom question, edit it, delete it, reload with saved custom questions, and reset back to template-only state. +4. Select saved style/session history, assert the open button enables, clear disables it, and opening fetches `/api/author-style/{id}` or `/api/brief-sessions/{id}` as needed. +5. Assert non-empty style and brief cards render `.artifact-card-header`, `.artifact-meta`, and `.artifact-section`, while raw Markdown/JSON previews remain populated. + +## Commands + +```sh +go test ./static ./cmd/server +go test ./... +``` + +## Review Result + +Commands passed after aligning the parallel history test fixture with the selected output-format validator: + +```sh +go test ./cmd/server ./internal/handlers ./static +go test ./internal/handlers ./static +go test ./static ./cmd/server +go test ./... +node --check static/js/script.js +git diff --check +``` + +This browser-contract document is still a validation checkpoint, not a close signal for #13. The next cut should add browser E2E over stubbed API responses once the project has a Playwright or equivalent harness. + +## Issue Policy + +- #13: keep open. Contract coverage is useful, but the issue asks for browser E2E. +- #14: keep open. SQLite exists, but queryable product memory is not fully exposed. +- #27: keep open unless the owner explicitly splits and closes the first saved-history picker cut. +- #28: keep open unless the owner explicitly splits and closes the first readable-card cut. 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 index 67b27be..23e6006 100644 --- a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md +++ b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md @@ -1,7 +1,7 @@ # Issue 27/28 history and artifact UI/API validation Date: 2026-05-03 -Branch: `codex/issue27-28-history-artifacts` +Branch: `codex/issue27-28-history-artifacts`; reviewed again from `codex/phase-c-history-e2e` ## Scope @@ -61,6 +61,27 @@ ok github.com/teradakousuke/note_maker/internal/handlers (cached) ok github.com/teradakousuke/note_maker/static (cached) ``` +Follow-up review on `codex/phase-c-history-e2e`: + +```sh +go test ./... +node --check static/js/script.js +git diff --check +``` + +These passed after the project/article/draft history follow-up was integrated. The follow-up adds SQLite-backed read routes and UI contract coverage, but it is still browser-contract coverage rather than a real browser E2E close signal for #13. + +Final follow-up validation after the fixture alignment: + +```sh +go test ./... +go test ./cmd/server ./internal/handlers ./static +node --check static/js/script.js +git diff --check +``` + +All passed. Project/article/draft history can continue as implementation work, but #13 still needs real browser E2E before it closes. + ## Acceptance Status - Saved style guides can be listed for picker UIs: done. @@ -75,5 +96,5 @@ ok github.com/teradakousuke/note_maker/static (cached) - 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). +- Project/article/draft history browsing from SQLite has a follow-up implementation through read APIs and history cards. Treat it as a separate #83 product-readiness cut from the original #27/#28 first-cut validation. +- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13). Static contract tests alone are not enough to close #13. diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go index ef1d278..cd8182a 100644 --- a/internal/handlers/workflow.go +++ b/internal/handlers/workflow.go @@ -42,6 +42,25 @@ type workflowStoreBackend interface { GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) } +type workflowHistoryReader interface { + GetProject(string) (sqliterepo.ProjectRecord, bool) + ListProjects() ([]sqliterepo.ProjectRecord, error) + GetArticle(string) (sqliterepo.ArticleRecord, bool) + ListArticlesByProject(string) ([]sqliterepo.ArticleRecord, error) + ListSourceSnapshots(string, string) ([]sqliterepo.SourceSnapshotRecord, error) + GetDraft(string) (sqliterepo.DraftRecord, bool) + ListDrafts(string) ([]sqliterepo.DraftRecord, error) + ListSectionRegenerations(string) ([]sqliterepo.SectionRegenerationRecord, error) +} + +type workflowHistoryWriter interface { + workflowHistoryReader + SaveProject(sqliterepo.ProjectRecord) error + SaveArticle(sqliterepo.ArticleRecord) error + SaveDraft(sqliterepo.DraftRecord) error + SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord) error +} + func newWorkflowStore() workflowStoreBackend { config := resolveWorkflowStorageConfig() setActiveWorkflowStorage(config) @@ -179,6 +198,101 @@ type workflowArtifactsResponse struct { StyleGuides []styleGuideArtifactResponse `json:"style_guides"` Sessions []briefSessionSummaryResponse `json:"sessions"` Briefs []briefArtifactResponse `json:"briefs"` + Projects []projectHistoryResponse `json:"projects,omitempty"` + Articles []articleHistoryResponse `json:"articles,omitempty"` + Drafts []draftHistoryResponse `json:"drafts,omitempty"` +} + +type projectHistoryListResponse struct { + Projects []projectHistoryResponse `json:"projects"` +} + +type projectHistoryDetailResponse struct { + Project projectHistoryResponse `json:"project"` + Articles []articleHistoryResponse `json:"articles"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"` +} + +type articleHistoryDetailResponse struct { + Article articleHistoryResponse `json:"article"` + Drafts []draftHistoryResponse `json:"drafts"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"` +} + +type draftHistoryDetailResponse struct { + Draft draftHistoryResponse `json:"draft"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"` + SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations"` +} + +type projectHistoryResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Articles []articleHistoryResponse `json:"articles,omitempty"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"` +} + +type articleHistoryResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id,omitempty"` + PersonaID string `json:"persona_id"` + OutputFormatID string `json:"output_format_id"` + BriefSessionID string `json:"brief_session_id,omitempty"` + CurrentDraftID string `json:"current_draft_id,omitempty"` + Title string `json:"title,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Drafts []draftHistoryResponse `json:"drafts,omitempty"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"` +} + +type sourceSnapshotHistoryResponse struct { + ID string `json:"id"` + ScopeType string `json:"scope_type"` + ScopeID string `json:"scope_id"` + Selector any `json:"selector"` + Profile any `json:"profile,omitempty"` + Article any `json:"article,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + FetchedAt string `json:"fetched_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type draftHistoryResponse struct { + ID string `json:"id"` + ArticleID string `json:"article_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + StyleProfileID string `json:"style_profile_id,omitempty"` + PersonaID string `json:"persona_id,omitempty"` + OutputFormatID string `json:"output_format_id,omitempty"` + Version int `json:"version"` + Markdown string `json:"markdown"` + ContentHash string `json:"content_hash,omitempty"` + Evaluation draftapp.StyleEvaluation `json:"evaluation"` + Verification draftapp.FinalVerification `json:"verification"` + QuestionTemplateVersion string `json:"question_template_version,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"` + SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations,omitempty"` +} + +type sectionRegenerationHistoryResponse struct { + ID string `json:"id"` + DraftID string `json:"draft_id"` + ArticleID string `json:"article_id,omitempty"` + SectionAnchor string `json:"section_anchor"` + SectionHeading string `json:"section_heading,omitempty"` + BaseVersion int `json:"base_version"` + Version int `json:"version"` + ReplacementMarkdown string `json:"replacement_markdown"` + UpdatedDraftMarkdown string `json:"updated_draft_markdown"` + UpdatedContentHash string `json:"updated_content_hash,omitempty"` + Verification draftapp.FinalVerification `json:"verification"` + CreatedAt string `json:"created_at,omitempty"` } type briefSessionTemplateResponse struct { @@ -273,6 +387,7 @@ type regenerateDraftSectionResponse struct { Section draftapp.MarkdownSection `json:"section"` ReplacementMarkdown string `json:"replacement_markdown"` UpdatedDraftMarkdown string `json:"updated_draft_markdown"` + RegenerationID string `json:"regeneration_id,omitempty"` } func toGenerateDraftResponse(result draftapp.GenerateResult, draftPath string) generateDraftResponse { @@ -833,10 +948,128 @@ func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) { } sortAuthorStyles(styles) sortBriefSessions(sessions) - respondWithJSON(w, http.StatusOK, workflowArtifactsResponse{ + response := workflowArtifactsResponse{ StyleGuides: toStyleGuideArtifactResponses(styles), Sessions: toBriefSessionSummaryResponses(sessions, briefs), Briefs: listBriefArtifactResponses(briefs), + } + if store, ok := workflowStore.(workflowHistoryReader); ok { + if projects, articles, drafts, err := listWorkflowHistoryResponses(store); err != nil { + respondWithError(w, "WORKFLOW_HISTORY_LIST_FAILED", "Failed to list project history", err.Error(), http.StatusInternalServerError) + return + } else { + response.Projects = projects + response.Articles = articles + response.Drafts = drafts + } + } + respondWithJSON(w, http.StatusOK, response) +} + +// ListProjectsHandler returns SQLite-backed project history when available. +func ListProjectsHandler(w http.ResponseWriter, r *http.Request) { + store, ok := workflowStore.(workflowHistoryReader) + if !ok { + respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: []projectHistoryResponse{}}) + return + } + projects, err := store.ListProjects() + if err != nil { + respondWithError(w, "PROJECT_LIST_FAILED", "Failed to list projects", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: toProjectHistoryResponses(projects)}) +} + +// GetProjectHandler returns one project with its article and source history. +func GetProjectHandler(w http.ResponseWriter, r *http.Request) { + store, ok := workflowStore.(workflowHistoryReader) + if !ok { + respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", "", http.StatusNotFound) + return + } + projectID := pathValue(r, "id") + project, ok := store.GetProject(projectID) + if !ok { + respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", projectID, http.StatusNotFound) + return + } + articles, err := store.ListArticlesByProject(projectID) + if err != nil { + respondWithError(w, "PROJECT_ARTICLES_LIST_FAILED", "Failed to list project articles", err.Error(), http.StatusInternalServerError) + return + } + sourceSnapshots, err := store.ListSourceSnapshots("project", projectID) + if err != nil { + respondWithError(w, "PROJECT_SOURCE_SNAPSHOT_LIST_FAILED", "Failed to list project source snapshots", err.Error(), http.StatusInternalServerError) + return + } + articleResponses := make([]articleHistoryResponse, 0, len(articles)) + for _, article := range articles { + response, _, err := articleHistoryResponseWithDetails(store, article) + if err != nil { + respondWithError(w, "PROJECT_ARTICLE_HISTORY_FAILED", "Failed to load project article history", err.Error(), http.StatusInternalServerError) + return + } + articleResponses = append(articleResponses, response) + } + projectResponse := toProjectHistoryResponse(project) + projectResponse.Articles = articleResponses + projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(sourceSnapshots) + respondWithJSON(w, http.StatusOK, projectHistoryDetailResponse{ + Project: projectResponse, + Articles: articleResponses, + SourceSnapshots: projectResponse.SourceSnapshots, + }) +} + +// GetArticleHandler returns one article with draft versions and source history. +func GetArticleHandler(w http.ResponseWriter, r *http.Request) { + store, ok := workflowStore.(workflowHistoryReader) + if !ok { + respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", "", http.StatusNotFound) + return + } + articleID := pathValue(r, "id") + article, ok := store.GetArticle(articleID) + if !ok { + respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", articleID, http.StatusNotFound) + return + } + articleResponse, _, err := articleHistoryResponseWithDetails(store, article) + if err != nil { + respondWithError(w, "ARTICLE_HISTORY_FAILED", "Failed to load article history", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusOK, articleHistoryDetailResponse{ + Article: articleResponse, + Drafts: articleResponse.Drafts, + SourceSnapshots: articleResponse.SourceSnapshots, + }) +} + +// GetDraftHandler returns one draft with regeneration history. +func GetDraftHandler(w http.ResponseWriter, r *http.Request) { + store, ok := workflowStore.(workflowHistoryReader) + if !ok { + respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", "", http.StatusNotFound) + return + } + draftID := pathValue(r, "id") + draft, ok := store.GetDraft(draftID) + if !ok { + respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", draftID, http.StatusNotFound) + return + } + draftResponse, err := draftHistoryResponseWithDetails(store, draft) + if err != nil { + respondWithError(w, "DRAFT_HISTORY_FAILED", "Failed to load draft history", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusOK, draftHistoryDetailResponse{ + Draft: draftResponse, + SourceSnapshots: draftResponse.SourceSnapshots, + SectionRegenerations: draftResponse.SectionRegenerations, }) } @@ -1030,7 +1263,8 @@ func GenerateDraftHandler(w http.ResponseWriter, r *http.Request) { respondWithError(w, "DRAFT_GENERATION_FAILED", "Failed to generate draft", err.Error(), http.StatusInternalServerError) return } - respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, "")) + draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format) + respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, draftPath)) } func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDraftRequest, profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) { @@ -1086,7 +1320,8 @@ func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDra _ = stream.Send("error", streamError{Code: "DRAFT_GENERATION_FAILED", Message: "Failed to generate draft", Detail: err.Error(), ElapsedMS: stream.ElapsedMS()}) return } - _ = stream.Send("result", toGenerateDraftResponse(result, "")) + draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format) + _ = stream.Send("result", toGenerateDraftResponse(result, draftPath)) _ = stream.Send("done", streamStatus{Status: "completed", Phase: "draft", Endpoint: endpoint, Model: model, StartedAt: stream.started.Format(time.RFC3339), ElapsedMS: stream.ElapsedMS(), Runes: len([]rune(result.Draft.Markdown())), Score: result.Evaluation.Comparison.Score}) } @@ -1097,8 +1332,27 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) { respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest) return } + pathID := pathValue(r, "id") + baseDraft, hasBaseDraft := historyDraftFromPath(pathID) + if hasBaseDraft { + if strings.TrimSpace(req.SessionID) == "" { + req.SessionID = baseDraft.SessionID + } + if strings.TrimSpace(req.StyleProfileID) == "" { + req.StyleProfileID = baseDraft.StyleProfileID + } + if strings.TrimSpace(req.PersonaID) == "" { + req.PersonaID = baseDraft.PersonaID + } + if strings.TrimSpace(req.OutputFormatID) == "" { + req.OutputFormatID = baseDraft.OutputFormatID + } + if strings.TrimSpace(req.DraftMarkdown) == "" { + req.DraftMarkdown = baseDraft.Markdown + } + } if strings.TrimSpace(req.SessionID) == "" { - req.SessionID = pathValue(r, "id") + req.SessionID = pathID } profile, guide, articleBrief, persona, format, ok := draftContextFromRequest(req.StyleProfileID, req.SessionID, req.PersonaID, req.OutputFormatID) if !ok { @@ -1126,10 +1380,12 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) { respondWithError(w, "DRAFT_SECTION_REGENERATE_FAILED", "Failed to regenerate draft section", err.Error(), http.StatusBadRequest) return } + regenerationID := saveSectionRegenerationHistory(baseDraft, hasBaseDraft, req, result) respondWithJSON(w, http.StatusOK, regenerateDraftSectionResponse{ Section: result.Section, ReplacementMarkdown: result.ReplacementMarkdown, UpdatedDraftMarkdown: result.UpdatedDraftMarkdown, + RegenerationID: regenerationID, }) } @@ -1174,6 +1430,153 @@ func draftContextFromRequest(styleProfileID, sessionID, personaID, formatID stri return profile, guide, articleBrief, persona, format, true } +func historyDraftFromPath(pathID string) (sqliterepo.DraftRecord, bool) { + if strings.TrimSpace(pathID) == "" { + return sqliterepo.DraftRecord{}, false + } + store, ok := workflowStore.(workflowHistoryReader) + if !ok { + return sqliterepo.DraftRecord{}, false + } + return store.GetDraft(pathID) +} + +func saveGeneratedDraftHistory(req generateDraftRequest, result draftapp.GenerateResult, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) string { + store, ok := workflowStore.(workflowHistoryWriter) + if !ok { + return "" + } + sessionID := strings.TrimSpace(req.SessionID) + if sessionID == "" { + return "" + } + now := time.Now().UTC() + projectID := historyRecordID("project", sessionID) + articleID := historyRecordID("article", sessionID) + title := briefTitle(sessionID, articleBrief) + projectName := firstNonEmpty(title, sessionID) + + project := sqliterepo.ProjectRecord{ + ID: projectID, + Name: projectName, + CreatedAt: now, + UpdatedAt: now, + Metadata: map[string]any{"session_id": sessionID}, + } + if existing, ok := store.GetProject(projectID); ok { + project.CreatedAt = existing.CreatedAt + if strings.TrimSpace(existing.Name) != "" { + project.Name = existing.Name + } + project.Metadata = mergeHistoryMetadata(existing.Metadata, project.Metadata) + } + if err := store.SaveProject(project); err != nil { + return "" + } + + drafts, err := store.ListDrafts(articleID) + if err != nil { + return "" + } + draftID := newID("draft") + version := len(drafts) + 1 + article := sqliterepo.ArticleRecord{ + ID: articleID, + ProjectID: projectID, + PersonaID: persona.ID, + OutputFormatID: format.ID, + BriefSessionID: sessionID, + CurrentDraftID: draftID, + Title: title, + CreatedAt: now, + UpdatedAt: now, + Metadata: map[string]any{"session_id": sessionID}, + } + if existing, ok := store.GetArticle(articleID); ok { + article.CreatedAt = existing.CreatedAt + article.Metadata = mergeHistoryMetadata(existing.Metadata, article.Metadata) + } + if err := store.SaveArticle(article); err != nil { + return "" + } + draft := sqliterepo.DraftRecord{ + ID: draftID, + ArticleID: articleID, + SessionID: sessionID, + StyleProfileID: firstNonEmpty(req.StyleProfileID, articleBrief.StyleProfileID), + PersonaID: persona.ID, + OutputFormatID: format.ID, + Version: version, + Markdown: result.Draft.Markdown(), + Evaluation: result.Evaluation, + Verification: result.Verification, + CreatedAt: now, + } + if err := store.SaveDraft(draft); err != nil { + return "" + } + return draftID +} + +func saveSectionRegenerationHistory(baseDraft sqliterepo.DraftRecord, hasBaseDraft bool, req regenerateDraftSectionRequest, result draftapp.RegenerateSectionResult) string { + if !hasBaseDraft { + return "" + } + store, ok := workflowStore.(workflowHistoryWriter) + if !ok { + return "" + } + regenerations, err := store.ListSectionRegenerations(baseDraft.ID) + if err != nil { + return "" + } + now := time.Now().UTC() + record := sqliterepo.SectionRegenerationRecord{ + ID: newID("regen"), + DraftID: baseDraft.ID, + ArticleID: baseDraft.ArticleID, + SectionAnchor: firstNonEmpty(req.SectionAnchor, result.Section.Anchor), + SectionHeading: result.Section.Heading, + BaseVersion: baseDraft.Version, + Version: len(regenerations) + baseDraft.Version + 1, + ReplacementMarkdown: result.ReplacementMarkdown, + UpdatedDraftMarkdown: result.UpdatedDraftMarkdown, + CreatedAt: now, + } + if record.BaseVersion <= 0 { + record.BaseVersion = 1 + } + if record.Version <= record.BaseVersion { + record.Version = record.BaseVersion + 1 + } + if err := store.SaveSectionRegeneration(record); err != nil { + return "" + } + return record.ID +} + +func historyRecordID(prefix, seed string) string { + seed = strings.TrimSpace(seed) + if seed == "" { + return newID(prefix) + } + return prefix + "_" + hex.EncodeToString([]byte(seed)) +} + +func mergeHistoryMetadata(existing, next map[string]any) map[string]any { + if len(existing) == 0 { + return next + } + merged := make(map[string]any, len(existing)+len(next)) + for key, value := range existing { + merged[key] = value + } + for key, value := range next { + merged[key] = value + } + return merged +} + func newDraftServiceWithVerifier(generator draftapp.TextGenerator, model string) (*draftapp.Service, error) { verifierClient, err := llamacpp.NewClientFromEnvForPurposeWithModel("VERIFY", model) if err != nil { @@ -1427,6 +1830,184 @@ func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) { }) } +func toProjectHistoryResponses(projects []sqliterepo.ProjectRecord) []projectHistoryResponse { + items := make([]projectHistoryResponse, 0, len(projects)) + for _, project := range projects { + items = append(items, toProjectHistoryResponse(project)) + } + return items +} + +func toProjectHistoryResponse(project sqliterepo.ProjectRecord) projectHistoryResponse { + return projectHistoryResponse{ + ID: project.ID, + Name: project.Name, + CreatedAt: formatOptionalTime(project.CreatedAt), + UpdatedAt: formatOptionalTime(project.UpdatedAt), + Metadata: project.Metadata, + } +} + +func listWorkflowHistoryResponses(store workflowHistoryReader) ([]projectHistoryResponse, []articleHistoryResponse, []draftHistoryResponse, error) { + projects, err := store.ListProjects() + if err != nil { + return nil, nil, nil, err + } + projectResponses := make([]projectHistoryResponse, 0, len(projects)) + articleResponses := []articleHistoryResponse{} + draftResponses := []draftHistoryResponse{} + for _, project := range projects { + projectResponse := toProjectHistoryResponse(project) + snapshots, err := store.ListSourceSnapshots("project", project.ID) + if err != nil { + return nil, nil, nil, err + } + projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots) + articles, err := store.ListArticlesByProject(project.ID) + if err != nil { + return nil, nil, nil, err + } + projectResponse.Articles = make([]articleHistoryResponse, 0, len(articles)) + for _, article := range articles { + articleResponse, drafts, err := articleHistoryResponseWithDetails(store, article) + if err != nil { + return nil, nil, nil, err + } + projectResponse.Articles = append(projectResponse.Articles, articleResponse) + articleResponses = append(articleResponses, articleResponse) + draftResponses = append(draftResponses, drafts...) + } + projectResponses = append(projectResponses, projectResponse) + } + return projectResponses, articleResponses, draftResponses, nil +} + +func articleHistoryResponseWithDetails(store workflowHistoryReader, article sqliterepo.ArticleRecord) (articleHistoryResponse, []draftHistoryResponse, error) { + articleResponse := toArticleHistoryResponse(article) + snapshots, err := store.ListSourceSnapshots("article", article.ID) + if err != nil { + return articleHistoryResponse{}, nil, err + } + articleResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots) + drafts, err := store.ListDrafts(article.ID) + if err != nil { + return articleHistoryResponse{}, nil, err + } + draftResponses := make([]draftHistoryResponse, 0, len(drafts)) + for _, draft := range drafts { + draftResponse, err := draftHistoryResponseWithDetails(store, draft) + if err != nil { + return articleHistoryResponse{}, nil, err + } + draftResponses = append(draftResponses, draftResponse) + } + articleResponse.Drafts = draftResponses + return articleResponse, draftResponses, nil +} + +func draftHistoryResponseWithDetails(store workflowHistoryReader, draft sqliterepo.DraftRecord) (draftHistoryResponse, error) { + draftResponse := toDraftHistoryResponse(draft) + snapshots, err := store.ListSourceSnapshots("draft", draft.ID) + if err != nil { + return draftHistoryResponse{}, err + } + draftResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots) + regenerations, err := store.ListSectionRegenerations(draft.ID) + if err != nil { + return draftHistoryResponse{}, err + } + draftResponse.SectionRegenerations = toSectionRegenerationHistoryResponses(regenerations) + return draftResponse, nil +} + +func toArticleHistoryResponse(article sqliterepo.ArticleRecord) articleHistoryResponse { + return articleHistoryResponse{ + ID: article.ID, + ProjectID: article.ProjectID, + PersonaID: article.PersonaID, + OutputFormatID: article.OutputFormatID, + BriefSessionID: article.BriefSessionID, + CurrentDraftID: article.CurrentDraftID, + Title: article.Title, + CreatedAt: formatOptionalTime(article.CreatedAt), + UpdatedAt: formatOptionalTime(article.UpdatedAt), + Metadata: article.Metadata, + } +} + +func toSourceSnapshotHistoryResponses(snapshots []sqliterepo.SourceSnapshotRecord) []sourceSnapshotHistoryResponse { + items := make([]sourceSnapshotHistoryResponse, 0, len(snapshots)) + for _, snapshot := range snapshots { + items = append(items, toSourceSnapshotHistoryResponse(snapshot)) + } + return items +} + +func toSourceSnapshotHistoryResponse(snapshot sqliterepo.SourceSnapshotRecord) sourceSnapshotHistoryResponse { + return sourceSnapshotHistoryResponse{ + ID: snapshot.ID, + ScopeType: snapshot.ScopeType, + ScopeID: snapshot.ScopeID, + Selector: snapshot.Selector, + Profile: snapshot.Profile, + Article: snapshot.Article, + ContentHash: snapshot.ContentHash, + FetchedAt: formatOptionalTime(snapshot.FetchedAt), + CreatedAt: formatOptionalTime(snapshot.CreatedAt), + } +} + +func toDraftHistoryResponses(drafts []sqliterepo.DraftRecord) []draftHistoryResponse { + items := make([]draftHistoryResponse, 0, len(drafts)) + for _, draft := range drafts { + items = append(items, toDraftHistoryResponse(draft)) + } + return items +} + +func toDraftHistoryResponse(draft sqliterepo.DraftRecord) draftHistoryResponse { + return draftHistoryResponse{ + ID: draft.ID, + ArticleID: draft.ArticleID, + SessionID: draft.SessionID, + StyleProfileID: draft.StyleProfileID, + PersonaID: draft.PersonaID, + OutputFormatID: draft.OutputFormatID, + Version: draft.Version, + Markdown: draft.Markdown, + ContentHash: draft.ContentHash, + Evaluation: draft.Evaluation, + Verification: draft.Verification, + QuestionTemplateVersion: draft.QuestionTemplateVersion, + CreatedAt: formatOptionalTime(draft.CreatedAt), + } +} + +func toSectionRegenerationHistoryResponses(regenerations []sqliterepo.SectionRegenerationRecord) []sectionRegenerationHistoryResponse { + items := make([]sectionRegenerationHistoryResponse, 0, len(regenerations)) + for _, regeneration := range regenerations { + items = append(items, toSectionRegenerationHistoryResponse(regeneration)) + } + return items +} + +func toSectionRegenerationHistoryResponse(regeneration sqliterepo.SectionRegenerationRecord) sectionRegenerationHistoryResponse { + return sectionRegenerationHistoryResponse{ + ID: regeneration.ID, + DraftID: regeneration.DraftID, + ArticleID: regeneration.ArticleID, + SectionAnchor: regeneration.SectionAnchor, + SectionHeading: regeneration.SectionHeading, + BaseVersion: regeneration.BaseVersion, + Version: regeneration.Version, + ReplacementMarkdown: regeneration.ReplacementMarkdown, + UpdatedDraftMarkdown: regeneration.UpdatedDraftMarkdown, + UpdatedContentHash: regeneration.UpdatedContentHash, + Verification: regeneration.Verification, + CreatedAt: formatOptionalTime(regeneration.CreatedAt), + } +} + func listBriefArtifactResponses(briefs map[string]briefdomain.ArticleBrief) []briefArtifactResponse { sessionIDs := make([]string, 0, len(briefs)) for sessionID := range briefs { diff --git a/internal/handlers/workflow_history_api_test.go b/internal/handlers/workflow_history_api_test.go new file mode 100644 index 0000000..fc1a11b --- /dev/null +++ b/internal/handlers/workflow_history_api_test.go @@ -0,0 +1,452 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + draftapp "github.com/teradakousuke/note_maker/internal/application/draft" + articledomain "github.com/teradakousuke/note_maker/internal/domain/article" + briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief" + outputformat "github.com/teradakousuke/note_maker/internal/domain/format" + personadomain "github.com/teradakousuke/note_maker/internal/domain/persona" + sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source" + "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory" + sqliterepo "github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite" +) + +func TestListProjectsHandlerReturnsSQLiteProjects(t *testing.T) { + setupHistoryAPIStore(t) + + response := httptest.NewRecorder() + ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil)) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + payload := decodeHistoryAPIPayload(t, response) + projects := historyAPIArray(t, payload, "projects") + if len(projects) != 1 { + t.Fatalf("projects = %d, want 1: %#v", len(projects), payload) + } + project := historyAPIObject(t, projects[0]) + if got := historyAPIString(project, "id", "ID"); got != "project-history" { + t.Fatalf("project id = %q, want project-history: %#v", got, project) + } + if got := historyAPIString(project, "name", "Name"); got != "History project" { + t.Fatalf("project name = %q, want History project: %#v", got, project) + } +} + +func TestListProjectsHandlerReturnsEmptyListForUnsupportedStore(t *testing.T) { + previous := workflowStore + workflowStore = memory.NewWorkflowStore() + t.Cleanup(func() { workflowStore = previous }) + + response := httptest.NewRecorder() + ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil)) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + payload := decodeHistoryAPIPayload(t, response) + if projects := historyAPIArray(t, payload, "projects"); len(projects) != 0 { + t.Fatalf("projects = %d, want 0: %#v", len(projects), payload) + } +} + +func TestListWorkflowArtifactsHandlerIncludesSQLiteHistory(t *testing.T) { + setupHistoryAPIStore(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.Projects) != 1 || payload.Projects[0].ID != "project-history" { + t.Fatalf("unexpected projects: %#v", payload.Projects) + } + if len(payload.Articles) != 1 || payload.Articles[0].ID != "article-history" { + t.Fatalf("unexpected articles: %#v", payload.Articles) + } + if len(payload.Drafts) != 1 || payload.Drafts[0].ID != "draft-history" { + t.Fatalf("unexpected drafts: %#v", payload.Drafts) + } + if len(payload.Projects[0].Articles) != 1 || len(payload.Articles[0].Drafts) != 1 || len(payload.Drafts[0].SectionRegenerations) != 1 { + t.Fatalf("workflow artifacts missing nested history: %#v", payload) + } +} + +func TestGetProjectHandlerReturnsArticlesAndSourceSnapshots(t *testing.T) { + setupHistoryAPIStore(t) + + request := httptest.NewRequest(http.MethodGet, "/api/projects/project-history", nil) + request = mux.SetURLVars(request, map[string]string{"id": "project-history"}) + response := httptest.NewRecorder() + GetProjectHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + project := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "project") + if got := historyAPIString(project, "id", "ID"); got != "project-history" { + t.Fatalf("project id = %q, want project-history: %#v", got, project) + } + articles := historyAPIArrayFromObject(t, project, "articles") + if len(articles) != 1 || historyAPIString(historyAPIObject(t, articles[0]), "id", "ID") != "article-history" { + t.Fatalf("unexpected project articles: %#v", articles) + } + snapshots := historyAPIArrayFromObject(t, project, "source_snapshots", "SourceSnapshots") + if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-project" { + t.Fatalf("unexpected project source snapshots: %#v", snapshots) + } +} + +func TestGetArticleHandlerReturnsDraftsAndSourceSnapshots(t *testing.T) { + setupHistoryAPIStore(t) + + request := httptest.NewRequest(http.MethodGet, "/api/articles/article-history", nil) + request = mux.SetURLVars(request, map[string]string{"id": "article-history"}) + response := httptest.NewRecorder() + GetArticleHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + article := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "article") + if got := historyAPIString(article, "id", "ID"); got != "article-history" { + t.Fatalf("article id = %q, want article-history: %#v", got, article) + } + drafts := historyAPIArrayFromObject(t, article, "drafts") + if len(drafts) != 1 || historyAPIString(historyAPIObject(t, drafts[0]), "id", "ID") != "draft-history" { + t.Fatalf("unexpected article drafts: %#v", drafts) + } + snapshots := historyAPIArrayFromObject(t, article, "source_snapshots", "SourceSnapshots") + if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-article" { + t.Fatalf("unexpected article source snapshots: %#v", snapshots) + } +} + +func TestGetDraftHandlerReturnsSectionRegenerations(t *testing.T) { + setupHistoryAPIStore(t) + + request := httptest.NewRequest(http.MethodGet, "/api/drafts/draft-history", nil) + request = mux.SetURLVars(request, map[string]string{"id": "draft-history"}) + response := httptest.NewRecorder() + GetDraftHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + draft := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "draft") + if got := historyAPIString(draft, "id", "ID"); got != "draft-history" { + t.Fatalf("draft id = %q, want draft-history: %#v", got, draft) + } + if got := historyAPIString(draft, "markdown", "Markdown"); got != "# History draft\n\nOriginal body." { + t.Fatalf("draft markdown = %q, want seeded markdown: %#v", got, draft) + } + regenerations := historyAPIArrayFromObject(t, draft, "section_regenerations", "SectionRegenerations") + if len(regenerations) != 1 { + t.Fatalf("section regenerations = %d, want 1: %#v", len(regenerations), regenerations) + } + regeneration := historyAPIObject(t, regenerations[0]) + if got := historyAPIString(regeneration, "section_anchor", "SectionAnchor"); got != "history-anchor" { + t.Fatalf("section anchor = %q, want history-anchor: %#v", got, regeneration) + } +} + +func TestHistoryReadHandlersReturnNotFoundForMissingIDs(t *testing.T) { + setupHistoryAPIStore(t) + + tests := []struct { + name string + target string + handler http.HandlerFunc + }{ + {name: "project", target: "/api/projects/missing", handler: GetProjectHandler}, + {name: "article", target: "/api/articles/missing", handler: GetArticleHandler}, + {name: "draft", target: "/api/drafts/missing", handler: GetDraftHandler}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, tt.target, nil) + request = mux.SetURLVars(request, map[string]string{"id": "missing"}) + response := httptest.NewRecorder() + + tt.handler(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body = %s", response.Code, response.Body.String()) + } + }) + } +} + +func TestSaveGeneratedDraftHistoryPersistsDraftAndRegeneration(t *testing.T) { + store := setupEmptyHistoryAPIStore(t) + persona, ok := personadomain.DefaultRegistry().Get(personadomain.IDCloudia) + if !ok { + t.Fatal("missing cloudia persona") + } + format, ok := outputformat.DefaultRegistry().Get(outputformat.IDNoteArticle) + if !ok { + t.Fatal("missing note format") + } + generatedDraft, err := articledomain.NewDraftForFormat("# Saved draft\n\nOriginal body.", format.ID) + if err != nil { + t.Fatalf("new generated draft: %v", err) + } + + draftID := saveGeneratedDraftHistory(generateDraftRequest{ + StyleProfileID: "style-save", + SessionID: "session-save", + }, draftapp.GenerateResult{ + Draft: generatedDraft, + Evaluation: draftapp.StyleEvaluation{ + Passed: true, + }, + Verification: draftapp.FinalVerification{ + Performed: true, + Passed: true, + Summary: "ok", + }, + }, briefdomain.ArticleBrief{ + Theme: "Saved draft", + StyleProfileID: "style-save", + PersonaID: persona.ID, + OutputFormatID: format.ID, + }, persona, format) + if draftID == "" { + t.Fatal("draft history id was empty") + } + + articleID := historyRecordID("article", "session-save") + article, ok := store.GetArticle(articleID) + if !ok || article.CurrentDraftID != draftID { + t.Fatalf("saved article = %#v ok=%v, want current draft %s", article, ok, draftID) + } + savedDraft, ok := store.GetDraft(draftID) + if !ok || savedDraft.ArticleID != articleID || savedDraft.Version != 1 { + t.Fatalf("saved draft = %#v ok=%v", savedDraft, ok) + } + + regenerationID := saveSectionRegenerationHistory(savedDraft, true, regenerateDraftSectionRequest{ + SectionAnchor: "saved", + }, draftapp.RegenerateSectionResult{ + Section: draftapp.MarkdownSection{Anchor: "saved", Heading: "Saved"}, + ReplacementMarkdown: "## Saved\n\nUpdated body.", + UpdatedDraftMarkdown: "# Saved draft\n\nUpdated body.", + }) + if regenerationID == "" { + t.Fatal("section regeneration history id was empty") + } + regenerations, err := store.ListSectionRegenerations(draftID) + if err != nil { + t.Fatalf("list section regenerations: %v", err) + } + if len(regenerations) != 1 || regenerations[0].ID != regenerationID || regenerations[0].Version != 2 { + t.Fatalf("unexpected regenerations: %#v", regenerations) + } +} + +func setupHistoryAPIStore(t *testing.T) { + t.Helper() + store := setupEmptyHistoryAPIStore(t) + seedHistoryAPIStore(t, store) +} + +func setupEmptyHistoryAPIStore(t *testing.T) *sqliterepo.WorkflowStore { + t.Helper() + previous := workflowStore + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + store, err := sqliterepo.NewWorkflowStoreDB(db) + if err != nil { + _ = db.Close() + t.Fatalf("new sqlite workflow store: %v", err) + } + t.Cleanup(func() { + workflowStore = previous + _ = store.Close() + }) + workflowStore = store + return store +} + +func seedHistoryAPIStore(t *testing.T, store *sqliterepo.WorkflowStore) { + t.Helper() + now := time.Unix(1710000000, 0).UTC() + project := sqliterepo.ProjectRecord{ + ID: "project-history", + Name: "History project", + CreatedAt: now, + UpdatedAt: now.Add(2 * time.Minute), + Metadata: map[string]any{"owner": "history-test"}, + } + if err := store.SaveProject(project); err != nil { + t.Fatalf("save project: %v", err) + } + article := sqliterepo.ArticleRecord{ + ID: "article-history", + ProjectID: project.ID, + PersonaID: personadomain.IDCloudia, + OutputFormatID: outputformat.IDZennArticle, + BriefSessionID: "brief-history", + Title: "History article", + CreatedAt: now, + UpdatedAt: now.Add(time.Minute), + } + if err := store.SaveArticle(article); err != nil { + t.Fatalf("save article: %v", err) + } + if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{ + ID: "snapshot-project", + ScopeType: "project", + ScopeID: project.ID, + Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: "cloudia"}, + Profile: &sourcedomain.ProfileSnapshot{ + Kind: sourcedomain.KindZenn, + Ref: "cloudia", + Title: "Cloudia", + FetchedAt: now, + }, + FetchedAt: now, + CreatedAt: now, + }); err != nil { + t.Fatalf("save project source snapshot: %v", err) + } + if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{ + ID: "snapshot-article", + ScopeType: "article", + ScopeID: article.ID, + Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: "https://zenn.dev/cloudia/articles/history"}, + Article: &sourcedomain.ArticleSnapshot{ + ID: "source-history", + Kind: sourcedomain.KindZenn, + URL: "https://zenn.dev/cloudia/articles/history", + Title: "Source history", + Content: "Source body", + FetchedAt: now, + }, + FetchedAt: now, + CreatedAt: now.Add(time.Second), + }); err != nil { + t.Fatalf("save article source snapshot: %v", err) + } + draft := sqliterepo.DraftRecord{ + ID: "draft-history", + ArticleID: article.ID, + SessionID: article.BriefSessionID, + StyleProfileID: "style-history", + PersonaID: article.PersonaID, + OutputFormatID: article.OutputFormatID, + Version: 1, + Markdown: "# History draft\n\nOriginal body.", + Evaluation: draftapp.StyleEvaluation{ + Passed: true, + }, + Verification: draftapp.FinalVerification{ + Performed: true, + Passed: true, + Summary: "ok", + }, + CreatedAt: now.Add(3 * time.Minute), + } + if err := store.SaveDraft(draft); err != nil { + t.Fatalf("save draft: %v", err) + } + if err := store.SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord{ + ID: "regen-history", + DraftID: draft.ID, + ArticleID: article.ID, + SectionAnchor: "history-anchor", + SectionHeading: "History", + BaseVersion: 1, + Version: 2, + ReplacementMarkdown: "## History\n\nUpdated body.", + UpdatedDraftMarkdown: "# History draft\n\nUpdated body.", + Verification: draftapp.FinalVerification{ + Performed: true, + Passed: true, + Summary: "regenerated ok", + }, + CreatedAt: now.Add(4 * time.Minute), + }); err != nil { + t.Fatalf("save section regeneration: %v", err) + } +} + +func decodeHistoryAPIPayload(t *testing.T, response *httptest.ResponseRecorder) any { + t.Helper() + var payload any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + return payload +} + +func historyAPIArray(t *testing.T, payload any, names ...string) []any { + t.Helper() + if array, ok := payload.([]any); ok { + return array + } + object := historyAPIObject(t, payload) + return historyAPIArrayFromObject(t, object, names...) +} + +func historyAPINamedObject(t *testing.T, payload any, names ...string) map[string]any { + t.Helper() + object := historyAPIObject(t, payload) + for _, name := range names { + if value, ok := object[name]; ok { + return historyAPIObject(t, value) + } + } + return object +} + +func historyAPIArrayFromObject(t *testing.T, object map[string]any, names ...string) []any { + t.Helper() + for _, name := range names { + if value, ok := object[name]; ok { + array, ok := value.([]any) + if !ok { + t.Fatalf("%s is %T, want array: %#v", name, value, object) + } + return array + } + } + t.Fatalf("none of %v found in response object: %#v", names, object) + return nil +} + +func historyAPIObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value is %T, want object: %#v", value, value) + } + return object +} + +func historyAPIString(object map[string]any, names ...string) string { + for _, name := range names { + if value, ok := object[name]; ok { + if text, ok := value.(string); ok { + return text + } + } + } + return "" +} diff --git a/static/css/style.css b/static/css/style.css index c8086af..f91b597 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -193,7 +193,7 @@ body { .history-picker-grid { display: grid; - grid-template-columns: minmax(160px, 0.65fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } @@ -221,6 +221,18 @@ body { background: var(--warning-bg); } +.history-detail { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.history-detail .artifact-card { + margin: 0; + min-width: 0; +} + .section-heading { display: flex; align-items: center; @@ -704,6 +716,7 @@ pre { .config-grid, .history-picker-grid, + .history-detail, .result-grid { grid-template-columns: 1fr; } diff --git a/static/history_ui_test.go b/static/history_ui_test.go index 76f87c5..f13634d 100644 --- a/static/history_ui_test.go +++ b/static/history_ui_test.go @@ -9,49 +9,427 @@ import ( "github.com/PuerkitoBio/goquery" ) +type staticContract struct { + document *goquery.Document + script string +} + 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) - } + contract := loadStaticContract(t) for _, selector := range []string{ "#history-persona-select", + "#history-project-select", + "#history-article-select", + "#history-draft-select", "#history-style-select", "#history-session-select", "#refresh-history-btn", "#open-history-btn", "#clear-history-selection-btn", "#history-status", + "#history-article-detail", + "#history-project-card", + "#history-article-card", + "#history-article-brief-card", + "#history-current-draft-card", + "#history-draft-versions-card", + "#history-source-snapshot-card", "#style-guide-card", "#brief-card", } { - if document.Find(selector).Length() != 1 { - t.Fatalf("selector %s count = %d, want 1", selector, document.Find(selector).Length()) - } + assertSelectorCount(t, contract.document, selector, 1) + } + + openHistoryButton := contract.document.Find("#open-history-btn") + if _, ok := openHistoryButton.Attr("disabled"); !ok { + t.Fatalf("#open-history-btn should start disabled until a history item is selected") } - source := string(script) - for _, want := range []string{ + assertScriptContains(t, contract.script, []string{ "const historyEndpoint = '/api/workflow/artifacts'", "loadWorkflowHistory", "normalizeWorkflowHistory", + "normalizeHistoryProject", + "normalizeHistoryArticle", + "normalizeHistoryDraft", + "renderHistoryArticleDetail", + "loadHistoryArticleDetail", + "loadHistoryDraftDetail", "renderStyleGuideCard", "renderBriefCard", "requestJSON(`${historyEndpoint}?", + "/api/projects/", + "/api/articles/", + "/api/drafts/", "/api/author-style/", "/api/brief-sessions/", + }) +} + +func TestModelSelectorConfigContract(t *testing.T) { + contract := loadStaticContract(t) + + for selector, label := range map[string]string{ + "#style-model": "文体分析モデル", + "#brief-model": "深掘り質問モデル", + "#draft-model": "下書き生成モデル", + "#verify-model": "最終検証モデル", + } { + assertSelectorCount(t, contract.document, selector, 1) + if got := strings.TrimSpace(contract.document.Find(`label[for="` + strings.TrimPrefix(selector, "#") + `"]`).Text()); got != label { + t.Fatalf("label for %s = %q, want %q", selector, got, label) + } + } + + assertScriptContains(t, contract.script, []string{ + "el.styleModel.addEventListener('change', saveModelConfig)", + "el.briefModel.addEventListener('change', saveModelConfig)", + "el.draftModel.addEventListener('change', saveModelConfig)", + "el.verifyModel.addEventListener('change', saveModelConfig)", + }) + assertFunctionContains(t, contract.script, "populateModelSelects", []string{ + "const available = models.length ? models : ['gemma4:31b']", + "style: config.models.style || 'gemma4:latest'", + "brief: config.models.brief || 'gemma4:e2b'", + "draft: config.models.draft || 'gemma4:31b'", + "verify: config.models.verify || 'gemma4:latest'", + "setOptions(el.styleModel, available, defaults.style)", + "setOptions(el.briefModel, available, defaults.brief)", + "setOptions(el.draftModel, available, defaults.draft)", + "setOptions(el.verifyModel, available, defaults.verify)", + "saveModelConfig()", + }) + assertFunctionContains(t, contract.script, "saveModelConfig", []string{ + "config.models = {", + "style: el.styleModel.value", + "brief: el.briefModel.value", + "draft: el.draftModel.value", + "verify: el.verifyModel.value", + "saveConfig()", + }) + assertFunctionContains(t, contract.script, "loadConfig", []string{ + "models: { style: 'gemma4:e2b', brief: 'qwen3.6:27b', draft: 'gemma4:31b', verify: 'gemma4:latest' }", + "localStorage.getItem(configStorageKey)", + "models: { ...fallback.models, ...(saved.models || {}) }", + }) +} + +func TestQuestionCRUDPersistenceContract(t *testing.T) { + contract := loadStaticContract(t) + + for _, selector := range []string{ + "#question-config-list", + "#add-question-btn", + "#reset-questions-btn", + } { + assertSelectorCount(t, contract.document, selector, 1) + } + if got := strings.TrimSpace(contract.document.Find("#add-question-btn").Text()); got != "質問を追加" { + t.Fatalf("#add-question-btn text = %q, want 質問を追加", got) + } + if got := strings.TrimSpace(contract.document.Find("#reset-questions-btn").Text()); got != "初期値に戻す" { + t.Fatalf("#reset-questions-btn text = %q, want 初期値に戻す", got) + } + + assertScriptContains(t, contract.script, []string{ + "el.addQuestion.addEventListener('click', addQuestion)", + "el.resetQuestions.addEventListener('click', resetQuestions)", + "const configStorageKey = 'note-maker-config-v1'", + }) + assertFunctionContains(t, contract.script, "createCustomQuestionRow", []string{ + "input.setAttribute('aria-label', '追加質問')", + "input.addEventListener('input'", + "config.customQuestions[index].text = input.value", + "saveConfig()", + "remove.textContent = '削除'", + "config.customQuestions.splice(index, 1)", + "renderQuestionConfig()", + }) + assertFunctionContains(t, contract.script, "addQuestion", []string{ + "config.customQuestions.push", + "id: `custom_${Date.now()}`", + "text: '追加で聞きたい質問を入力してください'", + "target_field: 'custom'", + "saveConfig()", + "renderQuestionConfig()", + }) + assertFunctionContains(t, contract.script, "resetQuestions", []string{ + "config.customQuestions = []", + "saveConfig()", + "loadQuestionTemplate()", + }) + assertFunctionContains(t, contract.script, "loadConfig", []string{ + "saved.customQuestions || saved.custom_questions || migrateLegacyQuestions(saved.questions)", + "customQuestions: normalizeQuestionList(savedCustomQuestions)", + }) + assertFunctionContains(t, contract.script, "currentQuestions", []string{ + "const templateIds = new Set", + "const templateTexts = new Set", + "normalizeQuestionList(config.customQuestions)", + "!templateIds.has(question.id)", + "!templateTexts.has(question.text)", + }) +} + +func TestHistoryOpenControlsApplyReadableCardsContract(t *testing.T) { + contract := loadStaticContract(t) + + assertScriptContains(t, contract.script, []string{ + "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)", + }) + assertFunctionContains(t, contract.script, "loadWorkflowHistory", []string{ + "state.historyLoading = true", + "state.selectedHistoryStyle = null", + "state.selectedHistorySession = null", + "fetchWorkflowHistoryIndex({ personaId, formatId })", + "normalizeWorkflowHistory(data, personaId, formatId)", + "renderHistoryPicker()", + }) + assertFunctionContains(t, contract.script, "renderHistoryPicker", []string{ + "renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択')", + "renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択')", + "el.openHistory.disabled = state.historyLoading || !historySelectionReady()", + "この書き手と出力先の保存済み履歴はまだありません。", + "選択できます。", + }) + assertFunctionContains(t, contract.script, "openSelectedHistory", []string{ + "loadHistoryStyleDetail", + "loadHistorySessionDetail", + "applyHistoryStyle(styleForSession)", + "await applyHistorySession(session)", + "選択した履歴を現在の作業状態に反映しました。", + }) + assertFunctionContains(t, contract.script, "applyHistorySession", []string{ + "config.mode.persona = data.personaId", + "config.mode.format = data.outputFormatId", + "await loadQuestionTemplate()", + "state.answers = data.answers || []", + "rememberQuestions(data.questions || state.templateQuestions)", + "renderTranscript", + "renderBriefCard(data.brief)", + "el.generateDraft.disabled = !state.profileId", + }) +} + +func TestHistoryProjectArticleDraftAdapterContract(t *testing.T) { + contract := loadStaticContract(t) + + assertFunctionContains(t, contract.script, "normalizeWorkflowHistory", []string{ + "const projectValues = arrayFrom(source.projects || source.Projects)", + "...arrayFrom(source.articles || source.Articles)", + "...arrayFrom(source.article_history || source.articleHistory)", + "...arrayFrom(source.drafts || source.Drafts)", + "...arrayFrom(source.draft_versions || source.draftVersions)", + "projectValues.flatMap", + "project.articles || project.Articles", + "article.drafts || article.Drafts || article.draft_versions || article.draftVersions", + "article.current_draft || article.currentDraft || article.draft || article.Draft", + "projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject)", + "articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle)", + "drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft)", + }) + assertFunctionContains(t, contract.script, "normalizeHistoryProject", []string{ + "const project = item.project || item.Project || {}", + "project.id || project.ID", + "project.title || project.Title || project.name || project.Name", + "project.persona_id || project.PersonaID", + "project.output_format_id || project.OutputFormatID", + "item.articles || item.Articles || project.articles || project.Articles", + "normalizeHistoryArticle({ project_id: id, ...article })", + }) + assertFunctionContains(t, contract.script, "normalizeHistoryArticle", []string{ + "const article = item.article || item.Article || {}", + "article.id || article.ID", + "article.title || article.Title", + "item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief", + "item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft", + "item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts", + "article.source_snapshot || article.SourceSnapshot", + }) + assertFunctionContains(t, contract.script, "normalizeHistoryDraft", []string{ + "const draft = item.draft || item.Draft || {}", + "draft.id || draft.ID", + "draft.article_id || draft.ArticleID", + "draft.session_id || draft.SessionID", + "draft.style_profile_id || draft.StyleProfileID", + "draft.markdown || draft.Markdown || draft.text || draft.Text", + "draft.score ?? draft.Score", + }) +} + +func TestHistoryWrappedDetailResponseContract(t *testing.T) { + contract := loadStaticContract(t) + + assertFunctionContains(t, contract.script, "loadHistoryProjectDetail", []string{ + "`/api/projects/${encodeURIComponent(normalized.id)}`", + "`/api/history/projects/${encodeURIComponent(normalized.id)}`", + "return normalizeHistoryProject({ ...item, ...data })", + }) + assertFunctionContains(t, contract.script, "loadHistoryArticleDetail", []string{ + "`/api/articles/${encodeURIComponent(normalized.id)}`", + "`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`", + "`/api/history/articles/${encodeURIComponent(normalized.id)}`", + "const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme)", + "return normalizeHistoryArticle({ ...item, ...detail })", + }) + assertFunctionContains(t, contract.script, "loadHistoryDraftDetail", []string{ + "`/api/drafts/${encodeURIComponent(normalized.id)}`", + "`/api/history/drafts/${encodeURIComponent(normalized.id)}`", + "return normalizeHistoryDraft({ ...item, ...data })", + }) + assertFunctionContains(t, contract.script, "requestFirstJSON", []string{ + "for (const url of urls.filter(Boolean))", + "return await requestJSON(url)", + "throw lastError || new Error('履歴詳細APIが見つかりません')", + }) +} + +func TestArtifactCardsReadableContract(t *testing.T) { + contract := loadStaticContract(t) + + for _, selector := range []string{ + "#style-result #style-guide-card", + "#style-result .artifact-raw #guide-preview", + "#brief-result #brief-card", + "#brief-result .artifact-raw #brief-preview", } { - if !strings.Contains(source, want) { + assertSelectorCount(t, contract.document, selector, 1) + } + + assertFunctionContains(t, contract.script, "renderStyleGuideCard", []string{ + "el.styleGuideCard.className = 'artifact-card empty'", + "文体ガイドはまだありません。", + "createArtifactHeader", + "['Profile', normalized.profileId || normalized.id]", + "['Guide', normalized.guideId]", + "['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)]", + "markdownSectionsForCard(markdown)", + "createArtifactSection", + }) + assertFunctionContains(t, contract.script, "renderBriefCard", []string{ + "el.briefCard.className = 'artifact-card empty'", + "記事ブリーフはまだありません。", + "createArtifactHeader", + "['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, 'must_include', 'MustInclude')]", + "createAnswerSection('追加回答', customAnswers)", + "createAnswerSection('深掘りメモ', deepDives)", + }) + assertFunctionContains(t, contract.script, "createArtifactHeader", []string{ + "header.className = 'artifact-card-header'", + "titleElement.textContent = title", + "meta.className = 'artifact-meta'", + }) + assertFunctionContains(t, contract.script, "createAnswerSection", []string{ + "state.questionTextById[questionId]", + "return `${question}: ${content}`", + }) +} + +func loadStaticContract(t *testing.T) staticContract { + t.Helper() + + 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) + } + return staticContract{document: document, script: string(script)} +} + +func assertSelectorCount(t *testing.T, document *goquery.Document, selector string, want int) { + t.Helper() + if got := document.Find(selector).Length(); got != want { + t.Fatalf("selector %s count = %d, want %d", selector, got, want) + } +} + +func assertScriptContains(t *testing.T, script string, wants []string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(script, want) { t.Fatalf("script missing %q", want) } } } + +func assertFunctionContains(t *testing.T, script, name string, wants []string) { + t.Helper() + body, ok := functionBody(script, name) + if !ok { + t.Fatalf("script missing function %s", name) + } + for _, want := range wants { + if !strings.Contains(body, want) { + t.Fatalf("function %s missing %q", name, want) + } + } +} + +func functionBody(script, name string) (string, bool) { + signature := "function " + name + "(" + start := strings.Index(script, signature) + if start < 0 { + return "", false + } + open := functionBodyOpen(script, start+len("function "+name)) + if open < 0 { + return "", false + } + depth := 0 + for index := open; index < len(script); index++ { + switch script[index] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return script[open : index+1], true + } + } + } + return "", false +} + +func functionBodyOpen(script string, parenStart int) int { + if parenStart >= len(script) || script[parenStart] != '(' { + return -1 + } + depth := 0 + for index := parenStart; index < len(script); index++ { + switch script[index] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + for next := index + 1; next < len(script); next++ { + if script[next] == '{' { + return next + } + if script[next] != ' ' && script[next] != '\n' && script[next] != '\t' && script[next] != '\r' { + return -1 + } + } + return -1 + } + } + } + return -1 +} diff --git a/static/index.html b/static/index.html index 882d4e7..7ab96e6 100644 --- a/static/index.html +++ b/static/index.html @@ -87,6 +87,18 @@

履歴から再開

+
+ + +
+
+ + +
+
+ + +
@@ -101,6 +113,14 @@

履歴から再開

+
+
+
+
+
+
+
+
diff --git a/static/js/script.js b/static/js/script.js index 427227e..3f9e068 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -51,11 +51,17 @@ document.addEventListener('DOMContentLoaded', () => { templateRequestId: 0, historyStyles: [], historySessions: [], + historyProjects: [], + historyArticles: [], + historyDrafts: [], historyLoading: false, historyError: '', historyRequestId: 0, selectedHistoryStyle: null, selectedHistorySession: null, + selectedHistoryProject: null, + selectedHistoryArticle: null, + selectedHistoryDraft: null, storageConfig: null, questionTextById: {}, lastSubmittedAnswer: '', @@ -78,12 +84,22 @@ document.addEventListener('DOMContentLoaded', () => { saveStorage: document.getElementById('save-storage-btn'), storageSummary: document.getElementById('storage-summary'), historyPersonaSelect: document.getElementById('history-persona-select'), + historyProjectSelect: document.getElementById('history-project-select'), + historyArticleSelect: document.getElementById('history-article-select'), + historyDraftSelect: document.getElementById('history-draft-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'), + historyArticleDetail: document.getElementById('history-article-detail'), + historyProjectCard: document.getElementById('history-project-card'), + historyArticleCard: document.getElementById('history-article-card'), + historyArticleBriefCard: document.getElementById('history-article-brief-card'), + historyCurrentDraftCard: document.getElementById('history-current-draft-card'), + historyDraftVersionsCard: document.getElementById('history-draft-versions-card'), + historySourceSnapshotCard: document.getElementById('history-source-snapshot-card'), questionConfigList: document.getElementById('question-config-list'), addQuestion: document.getElementById('add-question-btn'), resetQuestions: document.getElementById('reset-questions-btn'), @@ -129,6 +145,7 @@ document.addEventListener('DOMContentLoaded', () => { }; renderQuestionConfig(); + renderHistoryArticleDetail(); initializeModeControls(); checkModels(); loadStorageConfig(); @@ -142,6 +159,9 @@ document.addEventListener('DOMContentLoaded', () => { el.storageDriver.addEventListener('change', onStorageDriverChange); el.saveStorage.addEventListener('click', saveStorageConfig); el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory); + el.historyProjectSelect.addEventListener('change', selectHistoryProject); + el.historyArticleSelect.addEventListener('change', selectHistoryArticle); + el.historyDraftSelect.addEventListener('change', selectHistoryDraft); el.historyStyleSelect.addEventListener('change', selectHistoryStyle); el.historySessionSelect.addEventListener('change', selectHistorySession); el.refreshHistory.addEventListener('click', loadWorkflowHistory); @@ -818,7 +838,11 @@ document.addEventListener('DOMContentLoaded', () => { state.historyError = ''; state.selectedHistoryStyle = null; state.selectedHistorySession = null; + state.selectedHistoryProject = null; + state.selectedHistoryArticle = null; + state.selectedHistoryDraft = null; renderHistoryPicker(); + renderHistoryArticleDetail(); try { const data = await fetchWorkflowHistoryIndex({ personaId, formatId }); @@ -828,17 +852,24 @@ document.addEventListener('DOMContentLoaded', () => { const normalized = normalizeWorkflowHistory(data, personaId, formatId); state.historyStyles = normalized.styles; state.historySessions = normalized.sessions; + state.historyProjects = normalized.projects; + state.historyArticles = normalized.articles; + state.historyDrafts = normalized.drafts; } catch (error) { if (requestId !== state.historyRequestId) { return; } state.historyStyles = []; state.historySessions = []; + state.historyProjects = []; + state.historyArticles = []; + state.historyDrafts = []; state.historyError = historyErrorMessage(error); } finally { if (requestId === state.historyRequestId) { state.historyLoading = false; renderHistoryPicker(); + renderHistoryArticleDetail(); } } } @@ -855,16 +886,24 @@ document.addEventListener('DOMContentLoaded', () => { } function renderHistoryPicker() { + const articles = filteredHistoryArticles(); + const drafts = filteredHistoryDrafts(); + renderHistoryOptions(el.historyProjectSelect, state.historyProjects, 'プロジェクトを選択'); + renderHistoryOptions(el.historyArticleSelect, articles, '記事を選択'); + renderHistoryOptions(el.historyDraftSelect, drafts, '下書きを選択'); renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択'); renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択'); + el.historyProjectSelect.disabled = state.historyLoading || !state.historyProjects.length; + el.historyArticleSelect.disabled = state.historyLoading || !articles.length; + el.historyDraftSelect.disabled = state.historyLoading || !drafts.length; 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 = '保存済みの文体ガイドと取材セッションを読み込んでいます...'; + el.historyStatus.textContent = '保存済みのプロジェクト、記事、下書き、文体ガイド、取材セッションを読み込んでいます...'; return; } if (state.historyError) { @@ -872,15 +911,18 @@ document.addEventListener('DOMContentLoaded', () => { el.historyStatus.textContent = state.historyError; return; } - if (!state.historyStyles.length && !state.historySessions.length) { + if (!state.historyProjects.length && !state.historyArticles.length && !state.historyDrafts.length && !state.historyStyles.length && !state.historySessions.length) { el.historyStatus.className = 'history-status empty'; el.historyStatus.textContent = 'この書き手と出力先の保存済み履歴はまだありません。'; return; } + const projectCount = `${state.historyProjects.length}件のプロジェクト`; + const articleCount = `${state.historyArticles.length}件の記事`; + const draftCount = `${state.historyDrafts.length}件の下書き`; const styleCount = `${state.historyStyles.length}件の文体ガイド`; const sessionCount = `${state.historySessions.length}件の取材セッション`; el.historyStatus.className = 'history-status'; - el.historyStatus.textContent = `${styleCount} / ${sessionCount} を選択できます。`; + el.historyStatus.textContent = `${projectCount} / ${articleCount} / ${draftCount} / ${styleCount} / ${sessionCount} を選択できます。`; } function renderHistoryOptions(select, items, placeholder) { @@ -909,7 +951,13 @@ document.addEventListener('DOMContentLoaded', () => { } function historySelectionReady() { - return Boolean(el.historyStyleSelect.value || el.historySessionSelect.value); + return Boolean( + el.historyProjectSelect.value + || el.historyArticleSelect.value + || el.historyDraftSelect.value + || el.historyStyleSelect.value + || el.historySessionSelect.value, + ); } function historyErrorMessage(error) { @@ -964,6 +1012,84 @@ document.addEventListener('DOMContentLoaded', () => { } } + async function selectHistoryProject() { + state.selectedHistoryProject = findHistoryProject(el.historyProjectSelect.value); + state.selectedHistoryArticle = null; + state.selectedHistoryDraft = null; + el.historyArticleSelect.value = ''; + el.historyDraftSelect.value = ''; + renderHistoryPicker(); + renderHistoryArticleDetail(); + if (!state.selectedHistoryProject) { + return; + } + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = 'プロジェクト履歴を確認しています...'; + try { + state.selectedHistoryProject = await loadHistoryProjectDetail(state.selectedHistoryProject); + mergeProjectDetailIntoHistory(state.selectedHistoryProject); + renderHistoryPicker(); + renderHistoryArticleDetail(); + } catch (error) { + renderHistoryArticleDetail({ warning: `プロジェクト詳細APIは未接続です: ${error.message}` }); + renderHistoryPicker(); + } + } + + async function selectHistoryArticle() { + state.selectedHistoryArticle = findHistoryArticle(el.historyArticleSelect.value); + state.selectedHistoryDraft = null; + el.historyDraftSelect.value = ''; + renderHistoryPicker(); + renderHistoryArticleDetail(); + if (!state.selectedHistoryArticle) { + return; + } + if (state.selectedHistoryArticle.projectId && !state.selectedHistoryProject) { + state.selectedHistoryProject = findHistoryProject(state.selectedHistoryArticle.projectId); + if (state.selectedHistoryProject) { + el.historyProjectSelect.value = state.selectedHistoryProject.id; + } + } + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '記事履歴を確認しています...'; + try { + state.selectedHistoryArticle = await loadHistoryArticleDetail(state.selectedHistoryArticle); + mergeArticleDetailIntoHistory(state.selectedHistoryArticle); + renderHistoryPicker(); + renderHistoryArticleDetail(); + } catch (error) { + renderHistoryArticleDetail({ warning: `記事詳細APIは未接続です: ${error.message}` }); + renderHistoryPicker(); + } + } + + async function selectHistoryDraft() { + state.selectedHistoryDraft = findHistoryDraft(el.historyDraftSelect.value); + if (!state.selectedHistoryDraft) { + renderHistoryArticleDetail(); + el.openHistory.disabled = !historySelectionReady(); + return; + } + if (state.selectedHistoryDraft.articleId && !state.selectedHistoryArticle) { + state.selectedHistoryArticle = findHistoryArticle(state.selectedHistoryDraft.articleId); + if (state.selectedHistoryArticle) { + el.historyArticleSelect.value = state.selectedHistoryArticle.id; + } + } + el.historyStatus.className = 'history-status loading'; + el.historyStatus.textContent = '下書き履歴を確認しています...'; + try { + state.selectedHistoryDraft = await loadHistoryDraftDetail(state.selectedHistoryDraft); + mergeDraftDetailIntoHistory(state.selectedHistoryDraft); + renderHistoryPicker(); + renderHistoryArticleDetail(); + } catch (error) { + renderHistoryArticleDetail({ warning: `下書き詳細APIは未接続です: ${error.message}` }); + renderHistoryPicker(); + } + } + async function openSelectedHistory() { clearError(); el.historyStatus.className = 'history-status loading'; @@ -975,6 +1101,12 @@ document.addEventListener('DOMContentLoaded', () => { const session = el.historySessionSelect.value ? await loadHistorySessionDetail(state.selectedHistorySession || findHistorySession(el.historySessionSelect.value)) : null; + const article = el.historyArticleSelect.value + ? await loadHistoryArticleDetail(state.selectedHistoryArticle || findHistoryArticle(el.historyArticleSelect.value)) + : null; + const draft = el.historyDraftSelect.value + ? await loadHistoryDraftDetail(state.selectedHistoryDraft || findHistoryDraft(el.historyDraftSelect.value)) + : null; const styleForSession = !style && session?.styleProfileId ? await loadHistoryStyleDetail({ id: session.styleProfileId }) : style; @@ -985,7 +1117,13 @@ document.addEventListener('DOMContentLoaded', () => { if (session) { await applyHistorySession(session); } - if (!styleForSession && !session) { + if (article) { + await applyHistoryArticle(article); + } + if (draft) { + applyHistoryDraft(draft); + } + if (!styleForSession && !session && !article && !draft && !el.historyProjectSelect.value) { showError('開く履歴を選択してください'); return; } @@ -998,11 +1136,18 @@ document.addEventListener('DOMContentLoaded', () => { } function clearHistorySelection() { + el.historyProjectSelect.value = ''; + el.historyArticleSelect.value = ''; + el.historyDraftSelect.value = ''; el.historyStyleSelect.value = ''; el.historySessionSelect.value = ''; + state.selectedHistoryProject = null; + state.selectedHistoryArticle = null; + state.selectedHistoryDraft = null; state.selectedHistoryStyle = null; state.selectedHistorySession = null; renderHistoryPicker(); + renderHistoryArticleDetail(); } async function loadHistoryStyleDetail(item) { @@ -1027,6 +1172,81 @@ document.addEventListener('DOMContentLoaded', () => { return normalizeHistorySession({ ...item, ...data }); } + async function loadHistoryProjectDetail(item) { + if (!item) { + return null; + } + const normalized = normalizeHistoryProject(item); + if (normalized.articles.length) { + return normalized; + } + const data = await requestFirstJSON([ + `/api/projects/${encodeURIComponent(normalized.id)}`, + `/api/history/projects/${encodeURIComponent(normalized.id)}`, + ]); + return normalizeHistoryProject({ ...item, ...data }); + } + + async function loadHistoryArticleDetail(item) { + if (!item) { + return null; + } + const normalized = normalizeHistoryArticle(item); + if (hasArticleDetail(normalized)) { + return normalized; + } + const urls = [`/api/articles/${encodeURIComponent(normalized.id)}`]; + if (normalized.projectId) { + urls.push(`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`); + } + if (normalized.briefId) { + urls.push(`/api/briefs/${encodeURIComponent(normalized.briefId)}`); + } + urls.push(`/api/history/articles/${encodeURIComponent(normalized.id)}`); + const data = await requestFirstJSON(urls); + const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme) + ? { brief: data } + : data; + return normalizeHistoryArticle({ ...item, ...detail }); + } + + async function loadHistoryDraftDetail(item) { + if (!item) { + return null; + } + const normalized = normalizeHistoryDraft(item); + if (normalized.markdown || normalized.summary) { + return normalized; + } + const data = await requestFirstJSON([ + `/api/drafts/${encodeURIComponent(normalized.id)}`, + `/api/history/drafts/${encodeURIComponent(normalized.id)}`, + ]); + return normalizeHistoryDraft({ ...item, ...data }); + } + + async function requestFirstJSON(urls) { + let lastError = null; + for (const url of urls.filter(Boolean)) { + try { + return await requestJSON(url); + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('履歴詳細APIが見つかりません'); + } + + function hasArticleDetail(article) { + return Boolean( + article.brief + || article.currentDraft?.markdown + || article.currentDraft?.summary + || article.draftVersions.length + || article.sourceSnapshot, + ); + } + function applyHistoryStyle(item) { const data = normalizeHistoryStyle(item); state.profileId = data.profileId || data.id; @@ -1082,12 +1302,97 @@ document.addEventListener('DOMContentLoaded', () => { updateSectionControls(); } + async function applyHistoryArticle(item) { + const data = normalizeHistoryArticle(item); + state.selectedHistoryArticle = data; + state.profileId = data.styleProfileId || state.profileId; + state.sessionId = data.sessionId || state.sessionId; + 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(); + await loadQuestionTemplate(); + if (data.brief) { + state.completedBrief = data.brief; + renderBriefCard(data.brief); + el.briefPreview.textContent = JSON.stringify(data.brief, null, 2); + el.briefResult.classList.remove('hidden'); + el.generateDraft.disabled = !state.profileId; + } + const draft = state.selectedHistoryDraft || data.currentDraft; + if (draft) { + applyHistoryDraft(draft); + } + renderHistoryArticleDetail(); + } + + function applyHistoryDraft(item) { + const data = normalizeHistoryDraft(item); + state.selectedHistoryDraft = data; + state.sessionId = data.sessionId || state.sessionId; + state.profileId = data.styleProfileId || state.profileId; + const draftText = data.markdown || data.summary || ''; + if (draftText) { + el.markdownOutput.value = draftText; + syncDraftEditor(); + el.draftResult.classList.remove('hidden'); + setActiveTab('preview'); + } + el.draftStatus.textContent = historyDraftResumeText(data); + el.generateDraft.disabled = !state.profileId || !state.sessionId; + renderHistoryArticleDetail(); + } + 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 briefValues = arrayFrom(source.briefs || source.Briefs); const sessionValues = [ ...arrayFrom(source.sessions || source.brief_sessions || source.briefSessions || source.items || (Array.isArray(source) ? source : [])), - ...arrayFrom(source.briefs || source.Briefs), + ...briefValues, + ]; + const projectValues = arrayFrom(source.projects || source.Projects); + const projectArticleValues = projectValues.flatMap((project) => arrayFrom(project.articles || project.Articles) + .map((article) => ({ project_id: project.id || project.ID || project.project_id || project.projectId, ...article }))); + const sourceSnapshotValues = arrayFrom(source.source_snapshots || source.sourceSnapshots || source.SourceSnapshots); + const briefArticleValues = briefValues.map((brief) => ({ + article_id: brief.article_id || brief.articleId || brief.session_id || brief.sessionId || brief.id || brief.ID, + persona_id: brief.persona_id || brief.personaId, + output_format_id: brief.output_format_id || brief.outputFormatId, + brief, + })); + const snapshotArticleValues = sourceSnapshotValues.map((snapshot) => ({ + article_id: snapshot.article_id || snapshot.articleId || snapshot.id || snapshot.ID, + persona_id: snapshot.persona_id || snapshot.personaId, + output_format_id: snapshot.output_format_id || snapshot.outputFormatId, + source_snapshot: snapshot, + })); + const articleValues = [ + ...arrayFrom(source.articles || source.Articles), + ...arrayFrom(source.article_history || source.articleHistory), + ...projectArticleValues, + ...briefArticleValues, + ...snapshotArticleValues, + ]; + const articleDraftValues = articleValues.flatMap((article) => [ + ...arrayFrom(article.drafts || article.Drafts || article.draft_versions || article.draftVersions), + ...[article.current_draft || article.currentDraft || article.draft || article.Draft].filter(Boolean), + ].map((draft) => ({ + article_id: article.id || article.ID || article.article_id || article.articleId, + persona_id: article.persona_id || article.personaId, + output_format_id: article.output_format_id || article.outputFormatId, + ...draft, + }))); + const draftValues = [ + ...arrayFrom(source.drafts || source.Drafts), + ...arrayFrom(source.draft_versions || source.draftVersions), + ...articleDraftValues, ]; return { styles: styleValues.map(normalizeHistoryStyle) @@ -1096,6 +1401,15 @@ document.addEventListener('DOMContentLoaded', () => { sessions: uniqueHistoryItems(sessionValues.map(normalizeHistorySession) .filter((item) => item.id) .filter((item) => historyItemMatches(item, personaId, formatId))), + projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject) + .filter((item) => item.id) + .filter((item) => historyItemMatches(item, personaId, formatId))), + articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle) + .filter((item) => item.id) + .filter((item) => historyItemMatches(item, personaId, formatId))), + drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft) + .filter((item) => item.id) + .filter((item) => historyItemMatches(item, personaId, formatId))), }; } @@ -1144,6 +1458,89 @@ document.addEventListener('DOMContentLoaded', () => { }; } + function normalizeHistoryProject(item = {}) { + const project = item.project || item.Project || {}; + const id = String(item.project_id || item.projectId || project.id || project.ID || item.id || item.ID || '').trim(); + return { + ...item, + id, + title: item.title || item.name || item.label || item.display_name || item.displayName || project.title || project.Title || project.name || project.Name || id, + personaId: item.persona_id || item.personaId || project.persona_id || project.PersonaID || '', + outputFormatId: item.output_format_id || item.outputFormatId || project.output_format_id || project.OutputFormatID || '', + status: item.status || item.Status || project.status || project.Status || '', + articleCount: item.article_count ?? item.articleCount ?? project.article_count ?? project.ArticleCount, + articles: arrayFrom(item.articles || item.Articles || project.articles || project.Articles).map((article) => normalizeHistoryArticle({ project_id: id, ...article })), + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || project.updated_at || project.UpdatedAt || '', + createdAt: item.created_at || item.createdAt || project.created_at || project.CreatedAt || '', + }; + } + + function normalizeHistoryArticle(item = {}) { + const article = item.article || item.Article || {}; + const brief = item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief || null; + const currentDraft = normalizeMaybeDraft(item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft || null); + const draftVersions = arrayFrom(item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts) + .map((draft) => normalizeHistoryDraft({ article_id: item.article_id || item.articleId || article.id || article.ID || item.id || item.ID, ...draft })) + .filter((draft) => draft.id || draft.markdown || draft.summary); + const id = String(item.article_id || item.articleId || article.id || article.ID || item.id || item.ID || '').trim(); + const title = item.title || item.name || article.title || article.Title || briefField(brief, 'theme', 'Theme') || currentDraft?.title || id; + return { + ...item, + id, + projectId: item.project_id || item.projectId || article.project_id || article.ProjectID || '', + title, + theme: briefField(brief, 'theme', 'Theme'), + personaId: item.persona_id || item.personaId || article.persona_id || article.PersonaID || briefField(brief, 'persona_id', 'PersonaID') || '', + outputFormatId: item.output_format_id || item.outputFormatId || article.output_format_id || article.OutputFormatID || briefField(brief, 'output_format_id', 'OutputFormatID') || '', + styleProfileId: item.style_profile_id || item.styleProfileId || article.style_profile_id || article.StyleProfileID || briefField(brief, 'style_profile_id', 'StyleProfileID') || '', + sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || article.session_id || article.SessionID || '', + briefId: item.brief_id || item.briefId || article.brief_id || article.BriefID || '', + status: item.status || item.Status || item.phase || item.Phase || article.status || article.Status || '', + brief, + currentDraft, + draftVersions, + sourceSnapshot: item.source_snapshot || item.sourceSnapshot || article.source_snapshot || article.SourceSnapshot || null, + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || article.updated_at || article.UpdatedAt || '', + createdAt: item.created_at || item.createdAt || article.created_at || article.CreatedAt || '', + }; + } + + function normalizeMaybeDraft(draft) { + if (!draft) { + return null; + } + const normalized = normalizeHistoryDraft(draft); + return normalized.id || normalized.markdown || normalized.summary ? normalized : null; + } + + function normalizeHistoryDraft(item = {}) { + const draft = item.draft || item.Draft || {}; + const markdown = item.markdown || item.Markdown || item.draft_markdown || item.draftMarkdown || item.text || item.Text || item.body || item.Body || item.content || item.Content || draft.markdown || draft.Markdown || draft.text || draft.Text || ''; + const id = String(item.draft_id || item.draftId || draft.id || draft.ID || item.id || item.ID || '').trim(); + return { + ...item, + id, + articleId: item.article_id || item.articleId || draft.article_id || draft.ArticleID || '', + personaId: item.persona_id || item.personaId || draft.persona_id || draft.PersonaID || '', + outputFormatId: item.output_format_id || item.outputFormatId || draft.output_format_id || draft.OutputFormatID || '', + sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || draft.session_id || draft.SessionID || '', + styleProfileId: item.style_profile_id || item.styleProfileId || draft.style_profile_id || draft.StyleProfileID || '', + title: item.title || item.name || draft.title || draft.Title || markdownTitle(markdown) || id, + version: item.version ?? item.Version ?? item.attempt ?? item.Attempt ?? item.revision ?? item.Revision ?? '', + kind: item.kind || item.Kind || '', + status: item.status || item.Status || '', + markdown, + summary: item.summary || item.Summary || draft.summary || draft.Summary || '', + score: item.score ?? item.Score ?? item.quality_gate?.score ?? item.qualityGate?.score ?? draft.score ?? draft.Score, + passed: item.passed ?? item.Passed ?? item.evaluation?.passed ?? item.Evaluation?.Passed, + runes: item.runes ?? item.Runes ?? item.quality_gate?.runes ?? item.qualityGate?.runes, + validationError: item.validation_error || item.validationError || item.ValidationError || '', + verification: item.verification || item.Verification || null, + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || draft.updated_at || draft.UpdatedAt || '', + createdAt: item.created_at || item.createdAt || draft.created_at || draft.CreatedAt || '', + }; + } + function normalizeHistoryQuestion(question) { if (!question) { return null; @@ -1172,6 +1569,37 @@ document.addEventListener('DOMContentLoaded', () => { return state.historySessions.find((item) => item.id === id) || null; } + function findHistoryProject(id) { + return state.historyProjects.find((item) => item.id === id) || null; + } + + function findHistoryArticle(id) { + return state.historyArticles.find((item) => item.id === id) || null; + } + + function findHistoryDraft(id) { + return state.historyDrafts.find((item) => item.id === id) || null; + } + + function filteredHistoryArticles() { + const projectId = el.historyProjectSelect.value; + if (!projectId) { + return state.historyArticles; + } + return state.historyArticles.filter((article) => !article.projectId || article.projectId === projectId); + } + + function filteredHistoryDrafts() { + const articleId = el.historyArticleSelect.value; + if (!articleId) { + return state.historyDrafts; + } + const article = state.selectedHistoryArticle || findHistoryArticle(articleId); + const articleDrafts = article?.draftVersions || []; + const globalDrafts = state.historyDrafts.filter((draft) => !draft.articleId || draft.articleId === articleId); + return uniqueHistoryItems([...articleDrafts, ...globalDrafts]); + } + function uniqueHistoryItems(items) { const byId = new Map(); items.forEach((item) => { @@ -1183,6 +1611,248 @@ document.addEventListener('DOMContentLoaded', () => { return [...byId.values()]; } + function mergeProjectDetailIntoHistory(project) { + if (!project) { + return; + } + state.historyProjects = uniqueHistoryItems([project, ...state.historyProjects]); + if (project.articles.length) { + state.historyArticles = uniqueHistoryItems([...project.articles, ...state.historyArticles]); + } + } + + function mergeArticleDetailIntoHistory(article) { + if (!article) { + return; + } + state.historyArticles = uniqueHistoryItems([article, ...state.historyArticles]); + if (article.draftVersions.length) { + state.historyDrafts = uniqueHistoryItems([...article.draftVersions, ...state.historyDrafts]); + } + if (article.currentDraft) { + state.historyDrafts = uniqueHistoryItems([article.currentDraft, ...state.historyDrafts]); + } + } + + function mergeDraftDetailIntoHistory(draft) { + if (!draft) { + return; + } + state.historyDrafts = uniqueHistoryItems([draft, ...state.historyDrafts]); + } + + function renderHistoryArticleDetail(options = {}) { + renderHistoryProjectCard(state.selectedHistoryProject, options.warning); + renderHistoryArticleCard(state.selectedHistoryArticle, options.warning); + const article = state.selectedHistoryArticle; + const selectedDraft = state.selectedHistoryDraft; + renderHistoryBriefCard(article?.brief || null); + renderHistoryCurrentDraftCard(selectedDraft || article?.currentDraft || null); + renderHistoryDraftVersionsCard(article?.draftVersions || filteredHistoryDrafts()); + renderHistorySourceSnapshotCard(article?.sourceSnapshot || null); + } + + function renderHistoryProjectCard(project, warning) { + if (!project) { + fillArtifactCard(el.historyProjectCard, null, [], [], 'プロジェクトを選択すると、記事一覧と更新状況をここに表示します。'); + return; + } + fillArtifactCard( + el.historyProjectCard, + project.title || 'プロジェクト', + [ + ['Project', project.id], + ['Persona', project.personaId], + ['Format', project.outputFormatId], + ['Status', project.status], + ['Updated', formatDateTime(project.updatedAt)], + ], + [ + ['記事', [`${project.articleCount ?? project.articles.length ?? 0}件`]], + warning ? ['注意', [warning]] : null, + ].filter(Boolean), + ); + } + + function renderHistoryArticleCard(article, warning) { + if (!article) { + fillArtifactCard(el.historyArticleCard, null, [], warning ? [['注意', [warning]]] : [], '記事を選択すると、ブリーフ、下書き、参照ソースの要約をここに表示します。'); + return; + } + fillArtifactCard( + el.historyArticleCard, + article.title || '記事', + [ + ['Article', article.id], + ['Project', article.projectId], + ['Persona', article.personaId], + ['Format', article.outputFormatId], + ['Status', article.status], + ['Updated', formatDateTime(article.updatedAt)], + ], + [ + ['ブリーフ', [article.brief ? briefField(article.brief, 'theme', 'Theme') || '保存済み' : '未接続または未作成']], + ['下書き', [`${article.draftVersions.length}件のバージョン${article.currentDraft ? ' / 現在版あり' : ''}`]], + warning ? ['注意', [warning]] : null, + ].filter(Boolean), + ); + } + + function renderHistoryBriefCard(brief) { + if (!brief) { + fillArtifactCard(el.historyArticleBriefCard, null, [], [], '記事ブリーフはまだありません。詳細API接続後、テーマ、読者、含める内容を要約表示します。'); + return; + } + fillArtifactCard( + el.historyArticleBriefCard, + briefField(brief, 'theme', '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, 'must_include', 'MustInclude')]], + ['読後アクション', [briefField(brief, 'expected_reader_action', 'ExpectedReaderAction')]], + ], + ); + } + + function renderHistoryCurrentDraftCard(draft) { + if (!draft) { + fillArtifactCard(el.historyCurrentDraftCard, null, [], [], '現在の下書きはまだありません。下書き詳細API接続後、本文の冒頭と評価を表示します。'); + return; + } + fillArtifactCard( + el.historyCurrentDraftCard, + draft.title || '現在の下書き', + [ + ['Draft', draft.id], + ['Version', draft.version], + ['Score', draft.score === undefined ? '' : Number(draft.score).toFixed(1)], + ['Status', draft.status || draft.kind], + ['Updated', formatDateTime(draft.updatedAt)], + ], + [ + ['本文要約', summarizeDraftLines(draft, 5)], + draft.validationError ? ['検証メモ', [draft.validationError]] : null, + ].filter(Boolean), + ); + } + + function renderHistoryDraftVersionsCard(drafts) { + const versions = arrayFrom(drafts).filter((draft) => draft.id || draft.markdown || draft.summary); + if (!versions.length) { + fillArtifactCard(el.historyDraftVersionsCard, null, [], [], '下書きバージョンはまだありません。保存済みバージョンがあると番号、評価、更新日時を一覧表示します。'); + return; + } + fillArtifactCard( + el.historyDraftVersionsCard, + '下書きバージョン', + [['Versions', versions.length]], + [['一覧', versions.slice(0, 8).map(historyDraftVersionLine)]], + ); + } + + function renderHistorySourceSnapshotCard(snapshot) { + const normalized = normalizeSourceSnapshot(snapshot); + if (!normalized) { + fillArtifactCard(el.historySourceSnapshotCard, null, [], [], 'ソーススナップショットはまだありません。接続後、参照記事や取得日時を表示します。'); + return; + } + fillArtifactCard( + el.historySourceSnapshotCard, + normalized.title || 'ソーススナップショット', + [ + ['Fetched', formatDateTime(normalized.fetchedAt)], + ['Articles', normalized.articles.length], + ], + [ + ['参照ソース', normalized.articles.length ? normalized.articles.slice(0, 6).map(sourceArticleLine) : [normalized.summary]], + ], + ); + } + + function fillArtifactCard(card, title, metaItems, sections, emptyText) { + card.innerHTML = ''; + if (!title && !arrayFrom(sections).length) { + card.className = 'artifact-card empty'; + card.textContent = emptyText; + return; + } + card.className = 'artifact-card'; + if (title) { + card.appendChild(createArtifactHeader(title, metaItems)); + } + arrayFrom(sections).forEach(([sectionTitle, values]) => { + const visibleValues = arrayFrom(values).filter((value) => String(value || '').trim()); + if (visibleValues.length) { + card.appendChild(createArtifactSection(sectionTitle, visibleValues)); + } + }); + } + + function normalizeSourceSnapshot(snapshot) { + if (!snapshot) { + return null; + } + if (Array.isArray(snapshot)) { + return { + title: 'ソーススナップショット', + fetchedAt: '', + summary: '', + articles: snapshot, + }; + } + if (typeof snapshot === 'string') { + return { + title: 'ソーススナップショット', + fetchedAt: '', + summary: snapshot, + articles: [], + }; + } + const articles = arrayFrom(snapshot.articles || snapshot.Articles || snapshot.sources || snapshot.Sources); + return { + title: snapshot.title || snapshot.Title || snapshot.source_selector || snapshot.sourceSelector || '', + fetchedAt: snapshot.fetched_at || snapshot.fetchedAt || snapshot.created_at || snapshot.createdAt || '', + summary: snapshot.summary || snapshot.Summary || snapshot.url || snapshot.URL || '', + articles, + }; + } + + function sourceArticleLine(article) { + const title = article.title || article.Title || article.id || article.ID || '参照記事'; + const url = article.url || article.URL || ''; + const fetchedAt = formatDateTime(article.fetched_at || article.fetchedAt || article.at || article.At); + return [title, url, fetchedAt].filter(Boolean).join(' / '); + } + + function summarizeDraftLines(draft, limit) { + return compactTextLines(draft.markdown || draft.summary || '', limit).map((line) => line.replace(/^#{1,6}\s+/, '')); + } + + function historyDraftVersionLine(draft) { + const title = draft.title || draft.id || '下書き'; + const version = draft.version ? `v${draft.version}` : draft.kind || ''; + const score = draft.score === undefined ? '' : `score ${Number(draft.score).toFixed(1)}`; + const updatedAt = formatDateTime(draft.updatedAt || draft.createdAt); + return [version, title, score, updatedAt].filter(Boolean).join(' / '); + } + + function historyDraftResumeText(draft) { + const score = draft.score === undefined ? '' : ` / style score ${Number(draft.score).toFixed(1)}`; + const version = draft.version ? ` v${draft.version}` : ''; + return `保存済み下書き${version}をMarkdownエディタに反映しました${score}。`; + } + + function markdownTitle(markdown) { + const heading = String(markdown || '').split('\n').find((line) => line.trim().startsWith('# ')); + return heading ? heading.replace(/^#\s+/, '').trim() : ''; + } + function applyPersonaDefaults(forceFormat) { const persona = currentPersona();