diff --git a/cmd/server/main.go b/cmd/server/main.go index eb1f65a..393193a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,6 +45,7 @@ func registerRoutes(r *mux.Router) { r.HandleFunc("/api/config/storage", handlers.GetStorageConfigHandler).Methods("GET") r.HandleFunc("/api/config/storage", handlers.UpdateStorageConfigHandler).Methods("PATCH") r.HandleFunc("/api/personas", handlers.ListPersonasHandler).Methods("GET") + r.HandleFunc("/api/personas", handlers.CreatePersonaHandler).Methods("POST") r.HandleFunc("/api/formats", handlers.ListFormatsHandler).Methods("GET") r.HandleFunc("/api/history", handlers.ListWorkflowArtifactsHandler).Methods("GET") r.HandleFunc("/api/workflow/artifacts", handlers.ListWorkflowArtifactsHandler).Methods("GET") @@ -52,11 +53,14 @@ func registerRoutes(r *mux.Router) { r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST") r.HandleFunc("/api/author-style/analyze", handlers.AnalyzeAuthorStyleHandler).Methods("POST") r.HandleFunc("/api/author-style/{id}", handlers.GetAuthorStyleHandler).Methods("GET") + r.HandleFunc("/api/author-style/{id}", handlers.CreateStyleGuideVersionHandler).Methods("PATCH") + r.HandleFunc("/api/author-style/{id}/versions", handlers.CreateStyleGuideVersionHandler).Methods("POST") r.HandleFunc("/api/brief-sessions/templates", handlers.GetBriefSessionTemplateHandler).Methods("GET") r.HandleFunc("/api/brief-sessions", handlers.ListBriefSessionsHandler).Methods("GET") r.HandleFunc("/api/brief-sessions", handlers.CreateBriefSessionHandler).Methods("POST") r.HandleFunc("/api/briefs", handlers.ListBriefArtifactsHandler).Methods("GET") r.HandleFunc("/api/briefs/{id}", handlers.GetBriefArtifactHandler).Methods("GET") + r.HandleFunc("/api/briefs/{id}", handlers.UpdateBriefArtifactHandler).Methods("PATCH") r.HandleFunc("/api/brief-sessions/{id}", handlers.GetBriefSessionHandler).Methods("GET") r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST") r.HandleFunc("/api/brief-sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST") diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 6df47bb..4a03bea 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -30,7 +30,11 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) { {method: http.MethodGet, path: "/api/briefs/session-1"}, {method: http.MethodGet, path: "/api/models"}, {method: http.MethodGet, path: "/api/personas"}, + {method: http.MethodPost, path: "/api/personas"}, {method: http.MethodGet, path: "/api/formats"}, + {method: http.MethodPatch, path: "/api/author-style/style-1"}, + {method: http.MethodPost, path: "/api/author-style/style-1/versions"}, + {method: http.MethodPatch, path: "/api/briefs/session-1"}, } { request, err := http.NewRequest(tt.method, tt.path, nil) if err != nil { diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md index b531271..6335c63 100644 --- a/docs/adrs/0002-multi-persona-multi-format-extension.md +++ b/docs/adrs/0002-multi-persona-multi-format-extension.md @@ -51,7 +51,7 @@ Two personas ship pre-loaded: | `terisuke` | てりすけ | `note.com/cor_instrument`, `cor-jp.com/blog/*` | `note_article` | 一人称「僕」/「私」、内省+実体験ナラティブ、起業・キャリア・AI駆動開発、「~した話」「~てしまった件」 | | `cloudia` | 宇宙野クラウディア | `zenn.dev/cloudia`, `qiita.com/Cloudia_Cor_Inc` | `zenn_article` | 一人称「クラウディア」/「うち」、博多弁混じり、感嘆符・【前編】等の装飾、AI/JS/Pythonチュートリアル、感情的訴求 (「劇的に」「最強の」) | -Personas are user-extensible. Adding a third persona requires only registering it (no code changes inside the prompt builder). +Personas are user-extensible. Phase C is not complete with built-in persona selection alone. The `codex/phase-c-persona-history-polish` cut implements custom persona create/list with persistence; custom persona update/delete and richer source management remain future product work. Adding a third persona must not require code changes inside the prompt builder. ### OutputFormat @@ -99,7 +99,12 @@ The flat `data/workflow_store.json` snapshot is replaced by a SQLite-backed stor - `brief_sessions`, `brief_answers` — unchanged in shape, gain a `parent_answer_id` for fork-on-edit. - `drafts` — versioned per article with score history. -Acceptance criterion: any prior session can be reopened, its accumulated context shown as a transcript, and a new draft regenerated from any point in history. +Acceptance criteria: + +- any prior session can be reopened, its accumulated context shown as a transcript, and a new draft regenerated from any point in history; +- custom personas can be created, persisted, selected, and reopened after restart; +- human-readable style-guide and brief artifacts can be edited through explicit product actions without losing raw Markdown/JSON audit data; +- artifact edits create recoverable history or version records rather than silently replacing prior state. This subsumes Issue [#14](https://github.com/terisuke/note_maker/issues/14) (queryable database). Issue [#14](https://github.com/terisuke/note_maker/issues/14) is kept open as the umbrella tracker; the SQLite migration becomes its acceptance. @@ -155,7 +160,7 @@ New domain types under `internal/domain`: Additions: -- `GET /api/personas` / `POST /api/personas` / `PATCH /api/personas/{id}` — persona CRUD. +- `GET /api/personas` / `POST /api/personas` — built-in plus custom persona listing and custom persona creation. `PATCH /api/personas/{id}` remains future work. - `GET /api/formats` — read-only registry of available formats. - `GET /api/brief-sessions/templates?persona_id=X&format_id=Y` — composed fixed-question template for the selected persona and output format. - `POST /api/projects` / `GET /api/projects` / `GET /api/projects/{id}` — project management. @@ -173,7 +178,21 @@ Implemented history/artifact read subset as of the #27/#28 cut: - `GET /api/brief-sessions` — saved interview session summaries. - `GET /api/briefs` and `GET /api/briefs/{id}` — completed brief artifacts for readable card rendering and reuse. -These endpoints are intentionally narrower than the future project/article/draft artifact surface described above. They do not implement add-persona authoring UI, project/article browsing, draft version browsing, or broader edit persistence semantics. +Implemented project/article/draft read subset as of the Phase C history follow-up: + +- `GET /api/projects` and `GET /api/projects/{id}` — SQLite-backed project history and source snapshots when the active store supports them. +- `GET /api/articles/{id}` — one article with brief, current draft, draft versions, and source snapshots. +- `GET /api/drafts/{id}` — one draft with regeneration history. +- `GET /api/workflow/artifacts` includes project, article, and draft summaries when SQLite history is available. + +Implemented in the `codex/phase-c-persona-history-polish` cut: + +- `POST /api/personas` — stores a user-authored persona after validating required fields, reserved ids, and output format. +- `GET /api/personas` — returns built-in personas followed by persisted custom personas. +- `PATCH /api/author-style/{id}` and `POST /api/author-style/{id}/versions` — store edited style-guide cards as new saved guide versions. +- `PATCH /api/briefs/{id}` — updates the saved brief artifact without rewriting the original session answers. + +These endpoints are still narrower than the full Phase C product surface described above. They do not implement custom persona update/delete, rich persona source editing, brief-version tables, or editable project/article/draft/source-snapshot cards. ## Testing Strategy @@ -221,9 +240,10 @@ Current implementation status as of 2026-05-03: - Phase B2/B3/B4 are implemented: historical source acquisition works for note, Zenn, Qiita, Cor RSS, and Cor GitHub Markdown; all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Validation is recorded in [Issue 22 source fetcher validation](../validation/issue-22-source-fetchers-2026-05-02.md) and [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). - Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)). - Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)). -- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md). +- Phase 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 project/article/draft/source-snapshot history methods for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session/project/article/draft pickers, human-readable style-guide cards, article-brief cards, project/article cards, current-draft cards, draft-version cards, and source-snapshot 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 real browser E2E coverage: Python `pytest` plus Playwright starts the real Go server on a free localhost port, stubs application APIs in the browser, and covers model config persistence, custom question CRUD/reset, legacy localStorage migration, persona/format switching, interview start payloads, history opening/readable cards, edit/fork, streaming/cancel, and section regeneration. Validation is recorded in [Issue #13 Browser E2E Validation](../validation/issue-13-browser-e2e-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. Handler/server/static tests and browser E2E are green for this surface; broader Phase C product semantics remain tracked separately from #13. +- 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. Handler/server/static tests and browser E2E are green for this surface. +- The `codex/phase-c-persona-history-polish` cut implements custom persona create/list and editable brief/style card persistence. Memory and SQLite stores persist custom personas, SQLite restores them after reopen, the UI can add a persona and select/reload it, style-card edits save as a new guide version, and brief-card edits update the saved brief artifact. Validation is recorded in [Phase C persona/history/card polish validation](../validation/phase-c-persona-history-card-polish-2026-05-03.md). - Phase D1 is implemented and merged: handler tests now cover template selection, edit/fork errors, SSE follow-up and draft paths, completed-session draft fallback, regenerate-section context recovery, Analyze/Generate compatibility handlers, and SQLite driver selection. `go test ./internal/handlers -cover` reports 80%+ statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)). - Runtime runner support is implemented and merged: `cmd/scenario/live_media_matrix` reads the offline matrix, emits planned aggregate JSON/Markdown by default, and executes live Evo X2 draft runs only when `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` is used ([#57](https://github.com/terisuke/note_maker/issues/57)). - The 2026-05-03 browser 500 analysis showed an implementation drift: plain web-app startup still defaulted to workstation-local `127.0.0.1:8081`, while this ADR requires Evo X2 Tailnet as primary. Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the default order to Evo X2 Ollama over Tailnet → Evo X2 llama.cpp → workstation-local llama.cpp and makes the UI show the actual endpoint/model reported by SSE. @@ -237,8 +257,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. Close [#13](https://github.com/terisuke/note_maker/issues/13) with the browser E2E cut after linking the validation document and PR. -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, and richer project/article/draft history polish. +2. Treat [#13](https://github.com/terisuke/note_maker/issues/13) as covered by the browser E2E validation cut; do not move Phase C product gaps back into browser-coverage scope. +3. Keep [#14](https://github.com/terisuke/note_maker/issues/14) open for broader queryable product memory and split custom persona update/delete or brief-version history if those become required beyond this cut. 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 @@ -255,8 +275,8 @@ Filed 2026-05-02 as part of the PR that introduced this ADR. - B4 — [#24](https://github.com/terisuke/note_maker/issues/24) Seed persona library with `terisuke` and `cloudia` profiles. Implemented for the built-in registry: seeds include sources, default formats, and voice notes; live source re-analysis remains under [#22](https://github.com/terisuke/note_maker/issues/22). - B5 — [#25](https://github.com/terisuke/note_maker/issues/25) Format- and persona-aware fixed question sets - C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store. -- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — 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. 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. +- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — implemented for built-in and custom persona history reuse through `履歴から再開`, backed by `GET /api/workflow/artifacts`, `GET /api/author-style`, `GET /api/brief-sessions`, SQLite project/article/draft read routes, and `POST /api/personas`. Custom persona update/delete remains follow-up work. +- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented for style-guide and article-brief card edit/save flows, plus read-only project, article, draft-version, current-draft, and source-snapshot cards, with raw Markdown/JSON details preserved. Brief edits persist the saved artifact; style edits create new saved guide versions. - D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` — implemented in the current cut with 80.0% handler package coverage. - Runtime runner — [#57](https://github.com/terisuke/note_maker/issues/57) Add live LLM media-matrix runner and aggregate evaluator, feeding [#40](https://github.com/terisuke/note_maker/issues/40) — implemented in the current cut. - Runtime stabilization epic — [#40](https://github.com/terisuke/note_maker/issues/40) Stabilize Tailnet Evo X2 draft quality and runtime metrics. #70-#73 provide the prerequisite validation and diagnostics. [#74](https://github.com/terisuke/note_maker/issues/74) has passed the bounded Cloudia/Zenn and Cloudia/Qiita proofs plus the final `5/5` publishing-target matrix. diff --git a/docs/implementation-plans/issue-27-28-history-artifacts-api.md b/docs/implementation-plans/issue-27-28-history-artifacts-api.md index 30991ed..1a23a35 100644 --- a/docs/implementation-plans/issue-27-28-history-artifacts-api.md +++ b/docs/implementation-plans/issue-27-28-history-artifacts-api.md @@ -102,12 +102,22 @@ go test ./... git diff --check ``` +## Phase C Polish Follow-up + +The `codex/phase-c-persona-history-polish` cut adds: + +- custom persona create/list with memory and SQLite persistence, +- add-persona UI that saves and selects the custom persona, +- editable style-guide cards that save a new guide version, +- editable brief cards that save the updated brief artifact, +- browser E2E coverage for custom persona add/reload and brief/style save, cancel, and error states. + ## Remaining Phase C Work Not included in this cut: -- add-persona authoring UI, -- edit persistence beyond the existing fork-on-edit/session/brief save path, -- project/article/draft history browsing, -- draft version and section-regeneration artifact browsing, -- browser E2E for the new history flow under Issue #13. +- custom persona update/delete, +- richer persona source editing after create, +- project/article/draft editing and write workflows beyond the current read cards, +- richer draft-version and section-regeneration artifact operations beyond current browsing, +- browser E2E for the new history flow is covered by the later Issue #13 validation cut. diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md index 596e570..73163ad 100644 --- a/docs/implementation-plans/issue-adr-guardrails.md +++ b/docs/implementation-plans/issue-adr-guardrails.md @@ -13,11 +13,10 @@ This document maps GitHub issues to [ADR 0001](../adrs/0001-three-phase-local-ar | [#9](https://github.com/terisuke/note_maker/issues/9) | Draft generation from style guide and brief | Draft Generation | Draft generation must not fetch Note articles; it only consumes `WritingStyleGuide + ArticleBrief`. | | [#10](https://github.com/terisuke/note_maker/issues/10) | API, UI, and scenario integration | API Direction / Testing Strategy | `go test ./...` stays offline; network/LLM scenarios require explicit env vars. | -Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/0002-multi-persona-multi-format-extension.md#tracked-issues) for the new umbrella): +Active issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/0002-multi-persona-multi-format-extension.md#tracked-issues) for the new umbrella): | Issue | Scope | ADR Section | Guardrail | | --- | --- | --- | --- | -| [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E for model config and question CRUD | ADR 0002 §Testing Strategy | Phase D folds persona switch, format switch, edit-and-fork, streaming, regenerate-section into the E2E surface. | | [#14](https://github.com/terisuke/note_maker/issues/14) | Persistent queryable database | ADR 0002 §Persistence direction | SQLite migration is the acceptance for #14; multi-persona schema is mandatory. | | [#15](https://github.com/terisuke/note_maker/issues/15) | Desktop launcher packaging | Out of ADR 0002 scope | Tracked separately; depends on Phase C completion before packaging makes sense. | | [#36](https://github.com/terisuke/note_maker/issues/36) | local llama.cpp fallback quality | ADR 0001/0002 runtime validation | Non-blocking for Phase A. Do not promote fallback as production-quality until it passes strict draft thresholds. | @@ -31,7 +30,9 @@ Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/00 Current cut status: -- [#26](https://github.com/terisuke/note_maker/issues/26) is implemented as `internal/infrastructure/repository/sqlite` plus `WORKFLOW_STORE_DRIVER=sqlite` web-app opt-in. [#14](https://github.com/terisuke/note_maker/issues/14) remains the broader queryable-history umbrella until the UI/API surface is exposed. +- [#26](https://github.com/terisuke/note_maker/issues/26) is implemented as `internal/infrastructure/repository/sqlite` plus `WORKFLOW_STORE_DRIVER=sqlite` web-app opt-in. [#14](https://github.com/terisuke/note_maker/issues/14) remains the broader queryable-history umbrella for complete product memory beyond the current custom-persona and brief/style edit surface. +- The [#13](https://github.com/terisuke/note_maker/issues/13) browser-E2E gate is closed by the Playwright validation record. Do not track remaining Phase C product polish as #13 scope. +- The `codex/phase-c-persona-history-polish` cut implements custom persona create/list plus editable brief/style card persistence. Keep custom persona update/delete and broader version/history semantics separate from the #13 browser-coverage gate. - [#29](https://github.com/terisuke/note_maker/issues/29) reaches the handler coverage gate: `go test ./internal/handlers -cover` reports 80.0%. - [#57](https://github.com/terisuke/note_maker/issues/57) is implemented as `cmd/scenario/live_media_matrix`; it defaults to offline planned aggregate output and requires `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` for Evo X2 calls. - [#40](https://github.com/terisuke/note_maker/issues/40) is now an epic with sub-issues [#70](https://github.com/terisuke/note_maker/issues/70)-[#74](https://github.com/terisuke/note_maker/issues/74). The staged validation criteria are met for the current publishing-target scope: the final full matrix passed `5/5` with endpoint, phase models, elapsed time, score, runes, final verification, structural gates, quality gates, and artifacts recorded. @@ -45,6 +46,7 @@ Closed historical issues: | [#5](https://github.com/terisuke/note_maker/issues/5) | DDD boundary split | Provides package boundary precedent. | | [#6](https://github.com/terisuke/note_maker/issues/6) | API contract alignment | Existing compatibility endpoint remains while new workflow is added. | | [#11](https://github.com/terisuke/note_maker/issues/11) | Strict style threshold tuning | Threshold logic is in place; future persona-specific revisions must be tracked separately. | +| [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E for model config and question CRUD | Closed. Playwright coverage exists for model config persistence, custom questions, persona/format switching, history/cards, streaming/cancel, edit/fork, and regenerate-section. | | [#21](https://github.com/terisuke/note_maker/issues/21) | Persona and OutputFormat domain concepts | B1 landed early; remaining B work must not expand persistence assumptions until Phase C. | | [#22](https://github.com/terisuke/note_maker/issues/22) | Historical source acquisition | Zenn/Qiita/Cor blog sources are available; Cor blog style analysis should prefer GitHub Markdown over RSS summaries. | | [#23](https://github.com/terisuke/note_maker/issues/23) | Format prompt templates and validators | Format guides and validators exist; new formats must add validator + guide + scenario sample. | @@ -58,7 +60,7 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) ( - Phase A (Conversation UX): keep domain changes narrow to auditable conversation state transitions such as fork-on-edit. Must keep all existing `go test ./...` green without weakening expectations. - Phase A execution started with [#18](https://github.com/terisuke/note_maker/issues/18) because Tailnet Evo X2 runs are long enough that spinner-only UX is no longer acceptable. [#17](https://github.com/terisuke/note_maker/issues/17) follows and reuses the streaming primitives. - Phase B (Persona / OutputFormat): implemented for built-in personas, five formats, source acquisition, and question templates. Further persona/library expansion should wait for Phase C persistence. -- Phase C (SQLite store): repository interfaces stay; only implementations change. JSON-file store remains the compatibility path, but storage selection must be visible in the web settings UI rather than hidden behind make/env setup. +- Phase C (SQLite store): repository interfaces stay; only implementations change. JSON-file store remains the compatibility path, but storage selection must be visible in the web settings UI rather than hidden behind make/env setup. Phase C product completion requires persisted custom personas and editable human-readable artifacts, not only built-in persona selection and read-only cards; the current polish cut covers create/list and brief/style edits, while update/delete and broader artifact versioning remain follow-up scope. - Phase D (Quality): handler tests are mandatory before any further endpoint-heavy UI work lands. Coverage gate: `internal/handlers/workflow.go` ≥ 80 %. ## Architectural Guardrails diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md index 0600421..180c876 100644 --- a/docs/implementation-plans/next-implementation-cut.md +++ b/docs/implementation-plans/next-implementation-cut.md @@ -1,8 +1,12 @@ -# Next implementation cut +# Next implementation cut: Phase C persona/history/card polish Date: 2026-05-03 +Branch: `codex/phase-c-persona-history-polish` +Route: C, docs/coordination -This document translates the current open issue set into the next executable implementation sequence. The end state is unchanged: run Evo X2 Tailnet scenarios for note, Qiita, Zenn, and Cor.inc company blog with different themes, tones, and target lengths, then compare runtime, score, verification, and final output quality. +This document records the current Phase C persona/history/card polish cut. The runtime and browser-E2E gates now have validation records; this cut adds custom persona create/list and editable brief/style card persistence on top of the existing history surface. + +The remaining limitations are narrower: custom persona update/delete, richer persona source management, and full queryable/versioned product memory remain outside this cut. ## Current state @@ -48,8 +52,7 @@ Implemented in the `codex/issue13-browser-e2e` cut: 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), closure-ready after the browser E2E cut lands. +- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work and the project/article/draft read surface. - 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). @@ -58,11 +61,25 @@ Open and active: - Interview usability fixed before measurement: [#66](https://github.com/terisuke/note_maker/issues/66), with details in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md). - Style-source switching fixed before measurement: [#68](https://github.com/terisuke/note_maker/issues/68), with details in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md). -Remaining Phase C gaps after the current #27/#28 cut: +Resolved validation baseline: + +- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13) is closed by the Playwright validation cut. Track remaining Phase C product work under #14/#27/#28. + +Implemented in this Phase C polish cut: + +- `GET /api/personas` returns built-in and user-authored personas; `POST /api/personas` validates and persists custom personas. +- Memory and SQLite stores persist custom personas; SQLite restore after reopen is covered. +- The web UI exposes an add-persona form, selects the new persona after save, keeps the history persona selector aligned, and reloads the saved persona through the API. +- Style-guide cards can be edited and saved through `PATCH /api/author-style/{id}` / `POST /api/author-style/{id}/versions`; the server stores the edit as a new style-guide version. +- Brief cards can be edited and saved through `PATCH /api/briefs/{id}`; the saved artifact updates while existing session answers remain auditable. +- Phase C E2E covers custom persona add/reload plus brief/style card save, cancel, and error behavior. + +Remaining Phase C limitations: -- 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 now has server routes, response-shape contract coverage, frontend selectors, readable history cards, and browser E2E over those cards. Broader edit/add-persona semantics remain outside #13 and keep #14/#27/#28 open unless the owner explicitly accepts a narrower first-cut closure. +- Custom persona update/delete is not implemented. +- Persona source management is minimal: create accepts initial source metadata, while richer source editing remains future work. +- Brief-card edits update the saved brief artifact; they do not rewrite the original interview answers or create a separate brief-version table. +- #14 remains open for full queryable product memory and broader version/history semantics beyond this cut. ## Current Review Findings @@ -78,16 +95,16 @@ node --check static/js/script.js git diff --check ``` -No blocking code-risk finding remains in the targeted suite after the parallel fixes. The #13 browser E2E closure risk is resolved by the `tests/e2e` suite; #27/#28 still contain product scope that is broader than the first cut. +No blocking code-risk finding remains in the targeted suite after the parallel fixes. The #13 browser E2E closure risk is resolved by the `tests/e2e` suite. The Phase C polish validation now covers custom persona create/list and editable brief/style card persistence; #14 remains the broader product-memory umbrella. ## Issue Close/Open Proposal | Issue | Proposal | Rationale | |---|---|---| -| [#13](https://github.com/terisuke/note_maker/issues/13) | Close with the E2E PR | The new Playwright browser suite covers model config, question customisation and migration, persona/format switching, history open, readable cards, streaming/cancel, edit/fork, regenerate-section, and legacy localStorage migration. `python3 -m pytest tests/e2e -q`, `go test ./...`, and `git diff --check` passed. | -| [#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. | +| [#13](https://github.com/terisuke/note_maker/issues/13) | Closed | Browser E2E validation covers model config, questions, persona/format switching, history/cards, streaming/cancel, edit/fork, and regenerate-section. | +| [#14](https://github.com/terisuke/note_maker/issues/14) | Keep open | #26 gives the SQLite schema, the current branch exposes project/article/draft read cards, and this cut adds custom persona persistence plus editable brief/style cards. Broader queryable product memory, persona update/delete, and complete artifact version/history semantics remain. | +| [#27](https://github.com/terisuke/note_maker/issues/27) | Close with this PR if the issue owner accepts create/list as the custom-persona scope | Saved style-guide/session/project/article/draft selectors are implemented, custom personas can be created/listed/persisted, and E2E covers selecting the saved persona and loading its history after reload. Custom persona update/delete should be tracked separately if needed. | +| [#28](https://github.com/terisuke/note_maker/issues/28) | Close with this PR if the issue owner accepts brief/style edit persistence as the card scope | Readable style-guide and brief cards are editable with save/cancel/error behavior. Style edits create a new saved guide version; brief edits persist the saved artifact while leaving original session answers auditable. | ## Final evaluation target @@ -159,16 +176,15 @@ Use subagents with disjoint write scopes when implementation resumes: | Lane | Issue | Subagent role | Write scope | Done when | |---|---|---|---|---| -| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts | -| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | 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 | done for this cut | browser tests cover persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and legacy localStorage migration | -| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing polish are split from the #27/#28 first cut | +| A | [#14](https://github.com/terisuke/note_maker/issues/14) | Persistence worker | future SQLite/history API and validation docs | custom personas, edited artifacts, projects, articles, drafts, source snapshots, and versions are queryable as one coherent product history | +| B | Persona follow-up | Product worker | future persona edit/delete UI and API files | custom personas can be updated or removed without corrupting existing history references | +| C | Artifact follow-up | Product worker | future artifact version UI and API files | brief edits have explicit version/history semantics comparable to style-guide versions | -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. +Keep these lanes disjoint when implementation resumes; persona management and artifact history are adjacent but separable. ## Recommended order -1. Merge the #13 browser E2E cut and close #13 with the validation document. -2. Split the remaining Phase C product work into explicit follow-up issues before broadening implementation: add-persona authoring UI, broader edit persistence semantics, and project/article/draft artifact browsing polish from the #26 SQLite schema. -3. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts. +1. Use the #13 Playwright validation as the browser baseline; do not track Phase C product gaps as browser-E2E debt. +2. Merge this Phase C polish cut if the validation document stays green, then close #27/#28 according to the issue-owner scope decision. +3. Keep #14 open for complete queryable product memory and decide whether custom persona update/delete or brief-version tables need separate follow-up issues. 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 index 0e7b485..0c8ca09 100644 --- a/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md +++ b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md @@ -11,17 +11,17 @@ Added practical browser-adjacent coverage that remains part of `go test ./...`: - 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. +This validates contract shape, not user behavior in a real browser. The later Playwright validation cut covers the actual browser flows for Issue [#13](https://github.com/terisuke/note_maker/issues/13). -## Why Not Playwright Yet +## Original Playwright Gap -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. +At the time of the contract cut, the frontend script was a single `DOMContentLoaded` closure with no importable UI functions. A later cut added the heavier test harness with stubbed API routes and browser setup. 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 +## Playwright Path -Recommended scenarios when browser E2E is introduced: +Scenarios carried into the browser E2E cut: 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. @@ -49,7 +49,7 @@ 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. +This browser-contract document is a validation checkpoint. The later browser E2E validation over stubbed API responses closed #13. ## Browser E2E Follow-up @@ -77,11 +77,11 @@ git diff --check See [Issue #13 Browser E2E Validation](./issue-13-browser-e2e-2026-05-03.md) for the scenario list. -Issue #13 can close with the browser E2E cut. Remaining work is Phase C product scope rather than browser coverage scope. +#13 is closed by the browser E2E cut. Remaining work is Phase C product scope rather than browser coverage scope. ## Issue Policy -- #13: close with the browser E2E cut. +- #13: closed with the browser E2E cut. - #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-13-browser-e2e-2026-05-03.md b/docs/validation/issue-13-browser-e2e-2026-05-03.md index 01fd97e..1440367 100644 --- a/docs/validation/issue-13-browser-e2e-2026-05-03.md +++ b/docs/validation/issue-13-browser-e2e-2026-05-03.md @@ -35,7 +35,7 @@ git diff --check passed ## Closure Decision -Issue #13 can close with this cut. The original acceptance and later comments are covered by real browser tests: +Issue #13 is closed by this cut. The original acceptance and later comments are covered by real browser tests: - model dropdown population and persisted phase-model choices; - custom question add/edit/delete/reset; @@ -48,4 +48,4 @@ Issue #13 can close with this cut. The original acceptance and later comments ar - streaming draft UI, cancellation, failed/partial state recovery surface; - section regeneration candidate reject and accept flow. -Remaining work after this cut belongs to broader Phase C product scope, not #13: add-persona authoring UI, richer edit persistence/version semantics, and further project/article/draft browsing polish. +Remaining work after this cut belongs to broader Phase C product scope, not #13. The later Phase C persona/history/card polish cut adds custom persona create/list and editable brief/style card persistence; remaining product-memory work stays under #14 or follow-up issues. 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 23e6006..998a02b 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 @@ -10,7 +10,7 @@ This validation covers: - [#27](https://github.com/terisuke/note_maker/issues/27) first saved-history picker cut. - [#28](https://github.com/terisuke/note_maker/issues/28) first human-readable style-guide and brief artifact card cut. -It deliberately does not claim completion for add-persona authoring UI, broader edit persistence, project/article/draft history browsing, draft version browsing, or Browser E2E coverage. +It deliberately did not claim completion for add-persona authoring UI, broader edit persistence, project/article/draft history browsing, draft version browsing, or Browser E2E coverage. Later cuts add those pieces incrementally; see [Phase C persona/history/card polish validation](./phase-c-persona-history-card-polish-2026-05-03.md) for the custom persona and editable brief/style card follow-up. ## What changed @@ -69,7 +69,7 @@ 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. +These passed after the project/article/draft history follow-up was integrated. The follow-up adds SQLite-backed read routes and UI contract coverage; the later #13 Playwright cut supplies the browser E2E close signal. Final follow-up validation after the fixture alignment: @@ -80,7 +80,7 @@ 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. +All passed. Project/article/draft history can continue as Phase C product work under #14/#27/#28; #13 browser E2E is covered by the later Playwright validation cut. ## Acceptance Status @@ -94,7 +94,7 @@ All passed. Project/article/draft history can continue as implementation work, b ## Remaining Work -- Add-persona authoring UI is still unimplemented. -- Broader edit persistence beyond the existing fork-on-edit/session save flow is still unimplemented. -- Project/article/draft history browsing from SQLite 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. +- Add-persona authoring UI is implemented by the Phase C persona/history/card polish cut for create/list. Custom persona update/delete remains follow-up work. +- Brief/style card edit persistence is implemented by the Phase C polish cut. Brief edits update the saved artifact without rewriting original session answers; style edits create a new saved guide version. +- Project/article/draft history browsing from SQLite has a follow-up implementation through read APIs and history cards. Treat remaining full product-memory semantics as #14 follow-up. +- Browser E2E coverage for the new history picker and cards is recorded in [Issue #13 Browser E2E Validation](./issue-13-browser-e2e-2026-05-03.md). diff --git a/docs/validation/phase-c-persona-history-card-polish-2026-05-03.md b/docs/validation/phase-c-persona-history-card-polish-2026-05-03.md new file mode 100644 index 0000000..eddfd5b --- /dev/null +++ b/docs/validation/phase-c-persona-history-card-polish-2026-05-03.md @@ -0,0 +1,76 @@ +# Phase C Persona/History/Card Polish Validation + +Date: 2026-05-03 +Branch: `codex/phase-c-persona-history-polish` +Route: C, docs/coordination +Base: `develop` after PR #85 merge + +## Scope + +This validates the Phase C product polish cut for custom persona create/list and editable brief/style cards. + +This PR implements: + +- built-in plus custom persona listing through `GET /api/personas`; +- custom persona creation through `POST /api/personas`; +- custom persona persistence in memory and SQLite stores, including SQLite reopen coverage; +- add-persona UI that saves, selects, reloads, and aligns the history persona selector; +- saved style-guide and brief-session reuse through `履歴から再開`; +- SQLite-backed project/article/draft/source-snapshot read routes and history cards when the active store supports them; +- human-readable style-guide, brief, project, article, current-draft, draft-version, and source-snapshot cards; +- style-guide card edit/save through `PATCH /api/author-style/{id}` / `POST /api/author-style/{id}/versions`; +- brief card edit/save through `PATCH /api/briefs/{id}`; +- editable draft Markdown and section-regeneration candidate accept/reject flow; +- browser E2E baseline for #13 in `tests/e2e`. + +Known limitations: + +- custom persona update/delete is not implemented; +- richer persona source editing is not implemented after create; +- brief-card edits update the saved brief artifact but do not rewrite original session answers or create a separate brief-version table; +- project/article/draft/source-snapshot cards remain read-only; +- #14 remains open for broader queryable product memory and version/history semantics. + +## Local Validation + +Final validation commands: + +```sh +node --check static/js/script.js +go test ./cmd/server ./internal/handlers ./internal/infrastructure/repository/memory ./internal/infrastructure/repository/sqlite ./static +python3 -m pytest tests/e2e/test_phase_c_persona_history_polish.py tests/e2e/test_history_stream_regenerate.py -q +git diff --check +``` + +Final local results: + +```text +node --check static/js/script.js +passed + +go test ./cmd/server ./internal/handlers ./internal/infrastructure/repository/memory ./internal/infrastructure/repository/sqlite ./static +ok github.com/teradakousuke/note_maker/cmd/server +ok github.com/teradakousuke/note_maker/internal/handlers +ok github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory +ok github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite +ok github.com/teradakousuke/note_maker/static + +python3 -m pytest tests/e2e/test_phase_c_persona_history_polish.py tests/e2e/test_history_stream_regenerate.py -q +8 passed + +git diff --check +passed +``` + +## Issue Close/Open Proposal + +| Issue | Proposal | Required before closure | +|---|---|---| +| [#13](https://github.com/terisuke/note_maker/issues/13) | Closed | Browser E2E validation is already recorded in [Issue #13 Browser E2E Validation](./issue-13-browser-e2e-2026-05-03.md). | +| [#14](https://github.com/terisuke/note_maker/issues/14) | Keep open | This cut adds custom persona persistence and editable brief/style cards, but broader queryable product memory, persona update/delete, and complete artifact version/history semantics remain. | +| [#27](https://github.com/terisuke/note_maker/issues/27) | Close with this PR if create/list satisfies the persona authoring scope | Custom personas can be created, persisted, listed with built-ins, selected, reloaded, and used to fetch persona-scoped history. Track persona update/delete separately if needed. | +| [#28](https://github.com/terisuke/note_maker/issues/28) | Close with this PR if brief/style edit persistence satisfies the card scope | Brief and style cards support edit, cancel, save, and error handling. Style edits create a new saved guide version; brief edits persist the saved artifact while preserving raw/session audit data. | + +## Suggested Merge Comment + +Phase C persona/history/card polish validated. #13 is closed. This PR adds custom persona create/list with persistence, plus editable brief/style card persistence. Recommend keeping #14 open for broader queryable product memory and closing #27/#28 if the issue owner accepts create/list and brief/style edit persistence as the intended scope. diff --git a/docs/validation/runtime-ui-ddd-audit-2026-05-03.md b/docs/validation/runtime-ui-ddd-audit-2026-05-03.md index baacbaf..5efac5a 100644 --- a/docs/validation/runtime-ui-ddd-audit-2026-05-03.md +++ b/docs/validation/runtime-ui-ddd-audit-2026-05-03.md @@ -53,9 +53,8 @@ Open work that still matters: - #63: runtime default correction and browser evaluation unblocker. - #40: actual Tailnet Evo X2 quality/runtime scoring across media. -- #27 and #28: history picker plus readable brief/style/draft artifacts. -- #13: browser E2E over the now-stable UI flows. -- #14: umbrella for queryable product memory beyond the schema foundation. +- #27 and #28: custom persona create/list plus editable brief/style cards are covered by the Phase C polish cut; remaining scope is limited to any issue-owner requested update/delete or broader card semantics. +- #14: umbrella for queryable product memory beyond the schema, history read surface, custom persona create/list, and brief/style edit persistence. - #36 and #45: fallback quality and llama.cpp swap as P2 runtime work. - #15: desktop/app-like packaging after the browser workflow is stable. @@ -72,8 +71,8 @@ Known deviations remain: - `internal/handlers/workflow.go` is still too large and coordinates store lookup, runtime construction, SSE, compatibility handlers, and application calls in one file. - LLM clients are constructed directly in handlers. A runtime provider/use-case boundary would make Evo X2/fallback behavior easier to test and configure from the UI. -- The SQLite repository persists the right data, but the UI does not yet expose projects, article history, draft versions, verification history, or source snapshots as queryable product memory. That is why #14 stays open. +- The SQLite repository persists the right data and the UI now exposes project, article, draft-version, and source-snapshot cards. The Phase C polish cut adds custom persona create/list persistence and brief/style edit persistence. #14 remains open for complete queryable product memory, including custom persona update/delete and broader artifact version/history semantics. - Runtime configuration now has storage UI parity, but LLM endpoint/fallback configuration is still env/default driven. #63 fixes the default and visibility problem; a future settings surface can make runtime selection explicit. -- The frontend is still one static JavaScript file. That is acceptable for the current local-first prototype, but #13 should lock behavior with browser E2E before #27/#28 add more stateful UI. +- The frontend is still one static JavaScript file. That is acceptable for the current local-first prototype; the #13 browser E2E cut now provides a baseline before #27/#28 add more stateful UI. Conclusion: the domain model and application services match ADR 0002 well enough to continue. The largest architectural risk is not the domain vocabulary; it is handler-led orchestration and hidden runtime configuration. The next implementation sequence should reduce those two risks before the full Evo X2 media-matrix evaluation. diff --git a/internal/domain/persona/persona.go b/internal/domain/persona/persona.go index 74c2dff..d950fc5 100644 --- a/internal/domain/persona/persona.go +++ b/internal/domain/persona/persona.go @@ -1,12 +1,18 @@ package persona -import "strings" +import ( + "fmt" + "regexp" + "strings" +) const ( IDTerisuke = "terisuke" IDCloudia = "cloudia" ) +var customIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`) + // AuthorSource identifies public material used to derive a persona's style. type AuthorSource struct { Kind string `json:"kind"` @@ -32,6 +38,31 @@ type Persona struct { VoiceNotes VoiceNotes `json:"voice_notes"` } +// ValidateCustom checks the user-authored persona fields needed by prompt and UI flows. +func (p Persona) ValidateCustom() error { + if strings.TrimSpace(p.ID) == "" { + return fmt.Errorf("persona id is required") + } + if !customIDPattern.MatchString(strings.TrimSpace(p.ID)) { + return fmt.Errorf("persona id must use 2-64 lowercase letters, digits, hyphen, or underscore") + } + if strings.TrimSpace(p.DisplayName) == "" { + return fmt.Errorf("persona display name is required") + } + if strings.TrimSpace(p.DefaultFormat) == "" { + return fmt.Errorf("persona default format is required") + } + for i, source := range p.Sources { + if strings.TrimSpace(source.Kind) == "" { + return fmt.Errorf("persona source %d kind is required", i) + } + if strings.TrimSpace(source.Ref) == "" && strings.TrimSpace(source.URL) == "" { + return fmt.Errorf("persona source %d requires ref or url", i) + } + } + return nil +} + // PromptHint turns voice notes into concise draft-generation guidance. func (p Persona) PromptHint() string { var lines []string diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go index cd8182a..0a69726 100644 --- a/internal/handlers/workflow.go +++ b/internal/handlers/workflow.go @@ -39,6 +39,9 @@ type workflowStoreBackend interface { SaveBrief(string, briefdomain.ArticleBrief) error GetBrief(string) (briefdomain.ArticleBrief, bool) ListBriefs() (map[string]briefdomain.ArticleBrief, error) + SavePersona(personadomain.Persona) error + GetPersona(string) (personadomain.Persona, bool) + ListPersonas() ([]personadomain.Persona, error) GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) } @@ -95,6 +98,15 @@ type seedAuthorStyleRequest struct { OutputFormatID string `json:"output_format_id"` } +type createPersonaRequest struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + DefaultFormat string `json:"default_format"` + Sources []personadomain.AuthorSource `json:"sources"` + VoiceNotes personadomain.VoiceNotes `json:"voice_notes"` +} + type authorStyleResponse struct { ID string `json:"id"` ProfileID string `json:"profile_id"` @@ -146,6 +158,23 @@ type editBriefAnswerRequest struct { BriefModel string `json:"brief_model"` } +type updateStyleGuideRequest struct { + GuideMarkdown string `json:"guide_markdown"` + PreferredFirstPerson string `json:"preferred_first_person"` + RecurringThemes []string `json:"recurring_themes"` + ParagraphRhythm string `json:"paragraph_rhythm"` + SentenceRhythm string `json:"sentence_rhythm"` + HeadingGuidance string `json:"heading_guidance"` + QuoteGuidance string `json:"quote_guidance"` + OpeningPatterns []string `json:"opening_patterns"` + ConclusionPatterns []string `json:"conclusion_patterns"` + Warnings []string `json:"warnings"` +} + +type updateBriefResponse struct { + Brief briefArtifactResponse `json:"brief"` +} + type briefSessionResponse struct { SessionID string `json:"session_id"` StyleProfileID string `json:"style_profile_id"` @@ -508,9 +537,58 @@ func draftGenerationErrorPayload(result draftapp.GenerateResult, err error, code return response, true } -// ListPersonasHandler returns built-in writing personas. +// ListPersonasHandler returns built-in and user-authored writing personas. func ListPersonasHandler(w http.ResponseWriter, r *http.Request) { - respondWithJSON(w, http.StatusOK, personadomain.DefaultRegistry().List()) + personas, err := listPersonas() + if err != nil { + respondWithError(w, "PERSONA_LIST_FAILED", "Failed to list personas", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusOK, personas) +} + +// CreatePersonaHandler stores a user-authored writing persona. +func CreatePersonaHandler(w http.ResponseWriter, r *http.Request) { + var req createPersonaRequest + if err := decodeJSONRequest(r, &req); err != nil { + respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest) + return + } + persona := personadomain.Persona{ + ID: strings.TrimSpace(req.ID), + DisplayName: strings.TrimSpace(req.DisplayName), + Description: strings.TrimSpace(req.Description), + DefaultFormat: strings.TrimSpace(req.DefaultFormat), + Sources: req.Sources, + VoiceNotes: req.VoiceNotes, + } + if persona.Description == "" && persona.DisplayName != "" { + persona.Description = "User-authored persona: " + persona.DisplayName + } + if strings.TrimSpace(persona.VoiceNotes.Tone) == "" && persona.DisplayName != "" { + persona.VoiceNotes.Tone = "Write in the voice of " + persona.DisplayName + "." + } + if err := persona.ValidateCustom(); err != nil { + respondWithError(w, "INVALID_PERSONA", "Invalid persona", err.Error(), http.StatusBadRequest) + return + } + if _, ok := personadomain.DefaultRegistry().Get(persona.ID); ok { + respondWithError(w, "PERSONA_ID_RESERVED", "Persona id is reserved by a built-in persona", persona.ID, http.StatusConflict) + return + } + if _, ok := workflowStore.GetPersona(persona.ID); ok { + respondWithError(w, "PERSONA_ALREADY_EXISTS", "Persona already exists", persona.ID, http.StatusConflict) + return + } + if _, ok := outputformat.DefaultRegistry().Get(persona.DefaultFormat); !ok { + respondWithError(w, "UNKNOWN_OUTPUT_FORMAT", "Output format was not found", persona.DefaultFormat, http.StatusBadRequest) + return + } + if err := workflowStore.SavePersona(persona); err != nil { + respondWithError(w, "PERSONA_SAVE_FAILED", "Failed to save persona", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusCreated, persona) } // ListFormatsHandler returns built-in output formats. @@ -520,7 +598,7 @@ func ListFormatsHandler(w http.ResponseWriter, r *http.Request) { // GetBriefSessionTemplateHandler returns the composed fixed-question template. func GetBriefSessionTemplateHandler(w http.ResponseWriter, r *http.Request) { - persona, ok := personadomain.DefaultRegistry().Get(r.URL.Query().Get("persona_id")) + persona, ok := resolvePersona(r.URL.Query().Get("persona_id")) if !ok { respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", r.URL.Query().Get("persona_id"), http.StatusBadRequest) return @@ -548,7 +626,7 @@ func SeedAuthorStyleHandler(w http.ResponseWriter, r *http.Request) { respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest) return } - persona, ok := personadomain.DefaultRegistry().Get(req.PersonaID) + persona, ok := resolvePersona(req.PersonaID) if !ok { respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", req.PersonaID, http.StatusBadRequest) return @@ -582,7 +660,7 @@ func AnalyzeAuthorStyleHandler(w http.ResponseWriter, r *http.Request) { return } - persona, ok := personadomain.DefaultRegistry().Get(req.PersonaID) + persona, ok := resolvePersona(req.PersonaID) if !ok { respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", req.PersonaID, http.StatusBadRequest) return @@ -700,6 +778,37 @@ func findPersonaSource(persona personadomain.Persona, kind string) (personadomai return personadomain.AuthorSource{}, false } +func resolvePersona(id string) (personadomain.Persona, bool) { + normalized := personadomain.NormalizeID(id) + if persona, ok := personadomain.DefaultRegistry().Get(normalized); ok { + return persona, true + } + return workflowStore.GetPersona(normalized) +} + +func listPersonas() ([]personadomain.Persona, error) { + builtIns := personadomain.DefaultRegistry().List() + custom, err := workflowStore.ListPersonas() + if err != nil { + return nil, err + } + sort.SliceStable(custom, func(i, j int) bool { + return custom[i].ID < custom[j].ID + }) + personas := make([]personadomain.Persona, 0, len(builtIns)+len(custom)) + personas = append(personas, builtIns...) + seen := map[string]bool{} + for _, persona := range builtIns { + seen[persona.ID] = true + } + for _, persona := range custom { + if !seen[persona.ID] { + personas = append(personas, persona) + } + } + return personas, nil +} + func firstNonEmpty(values ...string) string { for _, value := range values { if cleaned := strings.TrimSpace(value); cleaned != "" { @@ -819,6 +928,30 @@ func GetAuthorStyleHandler(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, http.StatusOK, toAuthorStyleResponse(result)) } +// CreateStyleGuideVersionHandler stores an edited style guide as a new version. +func CreateStyleGuideVersionHandler(w http.ResponseWriter, r *http.Request) { + var req updateStyleGuideRequest + if err := decodeJSONRequest(r, &req); err != nil { + respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest) + return + } + base, ok := workflowStore.GetAuthorStyle(pathValue(r, "id")) + if !ok { + respondWithError(w, "AUTHOR_STYLE_NOT_FOUND", "Author style was not found", "", http.StatusNotFound) + return + } + updated, err := styleGuideVersionFromRequest(base, req) + if err != nil { + respondWithError(w, "INVALID_STYLE_GUIDE_VERSION", "Invalid style guide version", err.Error(), http.StatusBadRequest) + return + } + if err := workflowStore.SaveAuthorStyle(updated); err != nil { + respondWithError(w, "AUTHOR_STYLE_SAVE_FAILED", "Failed to save author style", err.Error(), http.StatusInternalServerError) + return + } + respondWithJSON(w, http.StatusCreated, toStyleGuideArtifactResponse(updated)) +} + // ListBriefSessionsHandler returns saved interview sessions for project history UIs. func ListBriefSessionsHandler(w http.ResponseWriter, r *http.Request) { sessions, err := workflowStore.ListSessions() @@ -855,7 +988,7 @@ func CreateBriefSessionHandler(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.SessionID) == "" { req.SessionID = newID("abs") } - persona, ok := personadomain.DefaultRegistry().Get(req.PersonaID) + persona, ok := resolvePersona(req.PersonaID) if !ok { respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", req.PersonaID, http.StatusBadRequest) return @@ -929,6 +1062,33 @@ func GetBriefArtifactHandler(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, http.StatusOK, toBriefArtifactResponse(sessionID, articleBrief, session, sessionOK)) } +// UpdateBriefArtifactHandler updates the saved brief artifact without rewriting session answers. +func UpdateBriefArtifactHandler(w http.ResponseWriter, r *http.Request) { + sessionID := pathValue(r, "id") + articleBrief, ok := workflowStore.GetBrief(sessionID) + if !ok { + respondWithError(w, "BRIEF_NOT_FOUND", "Brief was not found", sessionID, http.StatusNotFound) + return + } + fields, err := decodeBriefUpdateFields(r) + if err != nil { + respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", err.Error(), http.StatusBadRequest) + return + } + if err := applyBriefUpdateFields(&articleBrief, fields); err != nil { + respondWithError(w, "INVALID_BRIEF_UPDATE", "Invalid brief update", err.Error(), http.StatusBadRequest) + return + } + if err := workflowStore.SaveBrief(sessionID, articleBrief); err != nil { + respondWithError(w, "BRIEF_SAVE_FAILED", "Failed to save article brief", err.Error(), http.StatusInternalServerError) + return + } + session, sessionOK := workflowStore.GetSession(sessionID) + respondWithJSON(w, http.StatusOK, updateBriefResponse{ + Brief: toBriefArtifactResponse(sessionID, articleBrief, session, sessionOK), + }) +} + // ListWorkflowArtifactsHandler returns all currently reusable workflow artifacts. func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) { styles, err := workflowStore.ListAuthorStyles() @@ -1217,7 +1377,7 @@ func GenerateDraftHandler(w http.ResponseWriter, r *http.Request) { if formatID == "" { formatID = articleBrief.OutputFormatID } - persona, ok := personadomain.DefaultRegistry().Get(personaID) + persona, ok := resolvePersona(personaID) if !ok { respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", personaID, http.StatusBadRequest) return @@ -1411,7 +1571,7 @@ func draftContextFromRequest(styleProfileID, sessionID, personaID, formatID stri if strings.TrimSpace(personaID) == "" { personaID = articleBrief.PersonaID } - persona, ok := personadomain.DefaultRegistry().Get(personaID) + persona, ok := resolvePersona(personaID) if !ok { return authordomain.AuthorStyleProfile{}, authordomain.WritingStyleGuide{}, briefdomain.ArticleBrief{}, personadomain.Persona{}, outputformat.OutputFormat{}, false } @@ -1590,6 +1750,219 @@ func decodeJSONRequest(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } +func decodeBriefUpdateFields(r *http.Request) (map[string]string, error) { + defer r.Body.Close() + var raw map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + return nil, err + } + if fieldsRaw, ok := raw["fields"]; ok { + var fields map[string]string + if err := json.Unmarshal(fieldsRaw, &fields); err != nil { + return nil, fmt.Errorf("fields must be an object of string values") + } + rawFields := make(map[string]json.RawMessage, len(fields)) + for key, value := range fields { + encoded, _ := json.Marshal(value) + rawFields[key] = encoded + } + raw = rawFields + } + fields := make(map[string]string, len(raw)) + for key, value := range raw { + if key == "fields" { + continue + } + var content string + if err := json.Unmarshal(value, &content); err != nil { + return nil, fmt.Errorf("field %q must be a string", key) + } + fields[key] = content + } + if len(fields) == 0 { + return nil, fmt.Errorf("at least one brief field is required") + } + return fields, nil +} + +func applyBriefUpdateFields(articleBrief *briefdomain.ArticleBrief, fields map[string]string) error { + for key, value := range fields { + switch normalizeBriefFieldName(key) { + case "style_profile_id": + if strings.TrimSpace(value) == "" { + return fmt.Errorf("style_profile_id cannot be empty") + } + if _, _, ok := workflowStore.GetProfileAndGuide(value); !ok { + return fmt.Errorf("style_profile_id was not found") + } + articleBrief.StyleProfileID = strings.TrimSpace(value) + case "persona_id": + if strings.TrimSpace(value) == "" { + return fmt.Errorf("persona_id cannot be empty") + } + persona, ok := resolvePersona(value) + if !ok { + return fmt.Errorf("persona_id was not found") + } + articleBrief.PersonaID = persona.ID + case "output_format_id": + if strings.TrimSpace(value) == "" { + return fmt.Errorf("output_format_id cannot be empty") + } + format, ok := outputformat.DefaultRegistry().Get(value) + if !ok { + return fmt.Errorf("output_format_id was not found") + } + articleBrief.OutputFormatID = format.ID + case "theme": + articleBrief.Theme = strings.TrimSpace(value) + case "opening_episode": + articleBrief.OpeningEpisode = strings.TrimSpace(value) + case "reader": + articleBrief.Reader = strings.TrimSpace(value) + case "expected_reader_action": + articleBrief.ExpectedReaderAction = strings.TrimSpace(value) + case "must_include": + articleBrief.MustInclude = strings.TrimSpace(value) + case "personal_context": + articleBrief.PersonalContext = strings.TrimSpace(value) + case "exclusions": + articleBrief.Exclusions = strings.TrimSpace(value) + case "target_length_structure": + articleBrief.TargetLengthStructure = strings.TrimSpace(value) + case "tone_stance": + articleBrief.ToneStance = strings.TrimSpace(value) + default: + return fmt.Errorf("unsupported brief field %q", key) + } + } + for _, required := range []struct { + name string + value string + }{ + {"style_profile_id", articleBrief.StyleProfileID}, + {"persona_id", articleBrief.PersonaID}, + {"output_format_id", articleBrief.OutputFormatID}, + {"theme", articleBrief.Theme}, + {"reader", articleBrief.Reader}, + {"expected_reader_action", articleBrief.ExpectedReaderAction}, + {"must_include", articleBrief.MustInclude}, + {"personal_context", articleBrief.PersonalContext}, + {"target_length_structure", articleBrief.TargetLengthStructure}, + } { + if strings.TrimSpace(required.value) == "" { + return fmt.Errorf("%s cannot be empty", required.name) + } + } + return nil +} + +func normalizeBriefFieldName(name string) string { + name = strings.TrimSpace(name) + if alias, ok := map[string]string{ + "StyleProfileID": "style_profile_id", + "PersonaID": "persona_id", + "OutputFormatID": "output_format_id", + "Theme": "theme", + "OpeningEpisode": "opening_episode", + "Reader": "reader", + "ExpectedReaderAction": "expected_reader_action", + "MustInclude": "must_include", + "PersonalContext": "personal_context", + "Exclusions": "exclusions", + "TargetLengthStructure": "target_length_structure", + "ToneStance": "tone_stance", + }[name]; ok { + return alias + } + var builder strings.Builder + for i, r := range name { + if r == '-' || r == ' ' { + builder.WriteRune('_') + continue + } + if r >= 'A' && r <= 'Z' { + if i > 0 { + builder.WriteRune('_') + } + builder.WriteRune(r + ('a' - 'A')) + continue + } + builder.WriteRune(r) + } + return strings.ToLower(builder.String()) +} + +func styleGuideVersionFromRequest(base authorstyleapp.AnalyzeResult, req updateStyleGuideRequest) (authorstyleapp.AnalyzeResult, error) { + guide := base.Guide + changed := false + if value := strings.TrimSpace(req.PreferredFirstPerson); value != "" { + guide.PreferredFirstPerson = value + changed = true + } + if len(req.RecurringThemes) > 0 { + guide.RecurringThemes = cleanStringSlice(req.RecurringThemes) + changed = true + } + if value := strings.TrimSpace(req.ParagraphRhythm); value != "" { + guide.ParagraphRhythm = value + changed = true + } + if value := strings.TrimSpace(req.SentenceRhythm); value != "" { + guide.SentenceRhythm = value + changed = true + } + if value := strings.TrimSpace(req.HeadingGuidance); value != "" { + guide.HeadingGuidance = value + changed = true + } + if value := strings.TrimSpace(req.QuoteGuidance); value != "" { + guide.QuoteGuidance = value + changed = true + } + if len(req.OpeningPatterns) > 0 { + guide.OpeningPatterns = cleanStringSlice(req.OpeningPatterns) + changed = true + } + if len(req.ConclusionPatterns) > 0 { + guide.ConclusionPatterns = cleanStringSlice(req.ConclusionPatterns) + changed = true + } + if len(req.Warnings) > 0 { + guide.Warnings = cleanStringSlice(req.Warnings) + changed = true + } + if markdown := strings.TrimSpace(req.GuideMarkdown); markdown != "" { + guide.Markdown = markdown + changed = true + } else if changed { + guide.Markdown = authordomain.GuideMarkdown(guide) + } + if !changed { + return authorstyleapp.AnalyzeResult{}, fmt.Errorf("at least one style guide field is required") + } + if err := guide.Validate(); err != nil { + return authorstyleapp.AnalyzeResult{}, err + } + suffix := strings.TrimPrefix(newID("edit"), "edit_") + guide.ID = firstNonEmpty(base.Guide.ID, "guide") + "_edit_" + suffix + updated := base + updated.ID = firstNonEmpty(base.ID, "author_style") + "_edit_" + suffix + updated.Guide = guide + updated.CreatedAt = time.Now().UTC() + return updated, nil +} + +func cleanStringSlice(values []string) []string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + cleaned = append(cleaned, value) + } + } + return cleaned +} + func wantsEventStream(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept"), "text/event-stream") } diff --git a/internal/handlers/workflow_edit_test.go b/internal/handlers/workflow_edit_test.go index ac1d6fa..9e9e629 100644 --- a/internal/handlers/workflow_edit_test.go +++ b/internal/handlers/workflow_edit_test.go @@ -124,3 +124,105 @@ func TestEditBriefAnswerHandlerValidatesStoredSessionAndAnswer(t *testing.T) { }) } } + +func TestUpdateBriefArtifactHandlerUpdatesSavedBriefWithoutRewritingSessionHistory(t *testing.T) { + style := setupWorkflowStyle(t) + session := sessionWithFixedAnswers(t, "session-brief-edit", style.Profile.ID) + session.MarkDeepDiveSkipped() + brief, err := session.Complete() + if err != nil { + t.Fatalf("complete session: %v", err) + } + if err := workflowStore.SaveSession(session); err != nil { + t.Fatalf("save session: %v", err) + } + if err := workflowStore.SaveBrief(session.ID, brief); err != nil { + t.Fatalf("save brief: %v", err) + } + originalThemeAnswer := session.Answers[0].Content + + body := bytes.NewBufferString(`{"fields":{"theme":"Edited saved theme","tone_stance":"Edited saved tone"}}`) + request := httptest.NewRequest(http.MethodPatch, "/api/briefs/session-brief-edit", body) + request = mux.SetURLVars(request, map[string]string{"id": session.ID}) + response := httptest.NewRecorder() + + UpdateBriefArtifactHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload updateBriefResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Brief.Brief.Theme != "Edited saved theme" || payload.Brief.Brief.ToneStance != "Edited saved tone" { + t.Fatalf("brief was not updated: %#v", payload.Brief.Brief) + } + savedBrief, ok := workflowStore.GetBrief(session.ID) + if !ok || savedBrief.Theme != "Edited saved theme" { + t.Fatalf("saved brief = %#v ok=%v", savedBrief, ok) + } + savedSession, ok := workflowStore.GetSession(session.ID) + if !ok { + t.Fatal("session missing after brief edit") + } + if savedSession.Answers[0].Content != originalThemeAnswer { + t.Fatalf("session answer history was rewritten: %#v", savedSession.Answers[0]) + } +} + +func TestUpdateBriefArtifactHandlerValidatesFields(t *testing.T) { + style := setupWorkflowStyle(t) + if err := workflowStore.SaveBrief("session-brief-edit", briefdomain.ArticleBrief{ + StyleProfileID: style.Profile.ID, + PersonaID: "terisuke", + OutputFormatID: "note_article", + Theme: "Theme", + Reader: "Reader", + ExpectedReaderAction: "Action", + MustInclude: "Include", + PersonalContext: "Context", + TargetLengthStructure: "1200字", + }); err != nil { + t.Fatalf("save brief: %v", err) + } + + request := httptest.NewRequest(http.MethodPatch, "/api/briefs/session-brief-edit", bytes.NewBufferString(`{"fields":{"unknown":"value"}}`)) + request = mux.SetURLVars(request, map[string]string{"id": "session-brief-edit"}) + response := httptest.NewRecorder() + + UpdateBriefArtifactHandler(response, request) + + assertErrorResponse(t, response, http.StatusBadRequest, "INVALID_BRIEF_UPDATE") +} + +func TestCreateStyleGuideVersionHandlerAppendsEditableVersion(t *testing.T) { + style := setupWorkflowStyle(t) + body := bytes.NewBufferString(`{"guide_markdown":"- 一人称: 私\n- よく扱うテーマ: local workflow\n- 段落のリズム: short\n- 文のリズム: direct\n- 見出し: clear\n- 引用表現: sparing\n- 書き出し: concrete\n- 締め方: next action"}`) + request := httptest.NewRequest(http.MethodPatch, "/api/author-style/"+style.Profile.ID, body) + request = mux.SetURLVars(request, map[string]string{"id": style.Profile.ID}) + response := httptest.NewRecorder() + + CreateStyleGuideVersionHandler(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload styleGuideArtifactResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.AnalysisID == style.ID || payload.GuideID == style.Guide.ID { + t.Fatalf("style guide version did not get new ids: %#v", payload) + } + if payload.ProfileID != style.Profile.ID || payload.GuideMarkdown == style.Guide.Markdown { + t.Fatalf("unexpected style guide version: %#v", payload) + } + _, latestGuide, ok := workflowStore.GetProfileAndGuide(style.Profile.ID) + if !ok { + t.Fatal("updated profile lookup missing") + } + if latestGuide.ID != payload.GuideID { + t.Fatalf("profile lookup did not point to latest guide: %s want %s", latestGuide.ID, payload.GuideID) + } +} diff --git a/internal/handlers/workflow_persona_test.go b/internal/handlers/workflow_persona_test.go new file mode 100644 index 0000000..e4cc7a7 --- /dev/null +++ b/internal/handlers/workflow_persona_test.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + outputformat "github.com/teradakousuke/note_maker/internal/domain/format" + personadomain "github.com/teradakousuke/note_maker/internal/domain/persona" + "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory" +) + +func TestCreatePersonaHandlerStoresCustomPersonaAndListKeepsBuiltIns(t *testing.T) { + workflowStore = memory.NewWorkflowStore() + body := `{"id":"custom_writer","display_name":"Custom Writer","default_format":"note_article"}` + response := httptest.NewRecorder() + + CreatePersonaHandler(response, httptest.NewRequest(http.MethodPost, "/api/personas", bytes.NewBufferString(body))) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var created personadomain.Persona + if err := json.NewDecoder(response.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "custom_writer" || created.DefaultFormat != outputformat.IDNoteArticle { + t.Fatalf("unexpected created persona: %#v", created) + } + if created.Description == "" || created.VoiceNotes.Tone == "" { + t.Fatalf("expected defaulted optional persona fields: %#v", created) + } + + listResponse := httptest.NewRecorder() + ListPersonasHandler(listResponse, httptest.NewRequest(http.MethodGet, "/api/personas", nil)) + if listResponse.Code != http.StatusOK { + t.Fatalf("list status = %d, body = %s", listResponse.Code, listResponse.Body.String()) + } + var personas []personadomain.Persona + if err := json.NewDecoder(listResponse.Body).Decode(&personas); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(personas) < 3 || personas[0].ID != personadomain.IDTerisuke || personas[1].ID != personadomain.IDCloudia { + t.Fatalf("built-in personas were not preserved first: %#v", personas) + } + if personas[len(personas)-1].ID != "custom_writer" { + t.Fatalf("custom persona missing from list: %#v", personas) + } + if _, ok := workflowStore.GetPersona("custom_writer"); !ok { + t.Fatal("custom persona was not saved") + } +} + +func TestCreatePersonaHandlerValidatesRequiredFieldsAndFormat(t *testing.T) { + workflowStore = memory.NewWorkflowStore() + tests := []struct { + name string + body string + status int + code string + }{ + { + name: "missing id", + body: `{"display_name":"Custom","default_format":"note_article"}`, + status: http.StatusBadRequest, + code: "INVALID_PERSONA", + }, + { + name: "missing display name", + body: `{"id":"custom_writer","default_format":"note_article"}`, + status: http.StatusBadRequest, + code: "INVALID_PERSONA", + }, + { + name: "reserved built-in id", + body: `{"id":"terisuke","display_name":"Custom","description":"desc","default_format":"note_article","voice_notes":{"tone":"tone"}}`, + status: http.StatusConflict, + code: "PERSONA_ID_RESERVED", + }, + { + name: "unknown format", + body: `{"id":"custom_writer","display_name":"Custom","description":"desc","default_format":"missing","voice_notes":{"tone":"tone"}}`, + status: http.StatusBadRequest, + code: "UNKNOWN_OUTPUT_FORMAT", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := httptest.NewRecorder() + CreatePersonaHandler(response, httptest.NewRequest(http.MethodPost, "/api/personas", bytes.NewBufferString(tt.body))) + assertErrorResponse(t, response, tt.status, tt.code) + }) + } +} diff --git a/internal/infrastructure/repository/memory/workflow.go b/internal/infrastructure/repository/memory/workflow.go index e59c36d..318cfa4 100644 --- a/internal/infrastructure/repository/memory/workflow.go +++ b/internal/infrastructure/repository/memory/workflow.go @@ -10,6 +10,7 @@ import ( "github.com/teradakousuke/note_maker/internal/application/authorstyle" authordomain "github.com/teradakousuke/note_maker/internal/domain/author" briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief" + personadomain "github.com/teradakousuke/note_maker/internal/domain/persona" ) // WorkflowStore is an in-memory repository for the local three-phase workflow. @@ -22,12 +23,14 @@ type WorkflowStore struct { guideIndexes map[string]authorstyle.AnalyzeResult sessions map[string]briefdomain.ArticleBriefSession briefs map[string]briefdomain.ArticleBrief + personas map[string]personadomain.Persona } type workflowSnapshot struct { AuthorStyles map[string]authorstyle.AnalyzeResult `json:"author_styles"` Sessions map[string]briefdomain.ArticleBriefSession `json:"sessions"` Briefs map[string]briefdomain.ArticleBrief `json:"briefs"` + Personas map[string]personadomain.Persona `json:"personas,omitempty"` } // NewWorkflowStore creates an empty local workflow store. @@ -38,6 +41,7 @@ func NewWorkflowStore() *WorkflowStore { guideIndexes: make(map[string]authorstyle.AnalyzeResult), sessions: make(map[string]briefdomain.ArticleBriefSession), briefs: make(map[string]briefdomain.ArticleBrief), + personas: make(map[string]personadomain.Persona), } } @@ -164,6 +168,39 @@ func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error return briefs, nil } +// SavePersona stores a user-authored persona. +func (s *WorkflowStore) SavePersona(persona personadomain.Persona) error { + if err := persona.ValidateCustom(); err != nil { + return err + } + if persona.ID == "" { + return fmt.Errorf("persona id is required") + } + s.mu.Lock() + defer s.mu.Unlock() + s.personas[persona.ID] = persona + return s.persistLocked() +} + +// GetPersona returns a user-authored persona by ID. +func (s *WorkflowStore) GetPersona(id string) (personadomain.Persona, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + persona, ok := s.personas[id] + return persona, ok +} + +// ListPersonas returns all user-authored personas. +func (s *WorkflowStore) ListPersonas() ([]personadomain.Persona, error) { + s.mu.RLock() + defer s.mu.RUnlock() + personas := make([]personadomain.Persona, 0, len(s.personas)) + for _, persona := range s.personas { + personas = append(personas, persona) + } + return personas, nil +} + // GetProfileAndGuide returns style assets by profile, guide, or analysis ID. func (s *WorkflowStore) GetProfileAndGuide(id string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) { result, ok := s.GetAuthorStyle(id) @@ -190,6 +227,7 @@ func (s *WorkflowStore) load() error { s.authorStyles = nonNilAuthorStyles(snapshot.AuthorStyles) s.sessions = nonNilSessions(snapshot.Sessions) s.briefs = nonNilBriefs(snapshot.Briefs) + s.personas = nonNilPersonas(snapshot.Personas) s.rebuildIndexesLocked() return nil } @@ -205,6 +243,7 @@ func (s *WorkflowStore) persistLocked() error { AuthorStyles: s.authorStyles, Sessions: s.sessions, Briefs: s.briefs, + Personas: s.personas, } encoded, err := json.MarshalIndent(snapshot, "", " ") if err != nil { @@ -256,3 +295,10 @@ func nonNilBriefs(values map[string]briefdomain.ArticleBrief) map[string]briefdo } return values } + +func nonNilPersonas(values map[string]personadomain.Persona) map[string]personadomain.Persona { + if values == nil { + return make(map[string]personadomain.Persona) + } + return values +} diff --git a/internal/infrastructure/repository/memory/workflow_test.go b/internal/infrastructure/repository/memory/workflow_test.go index e814b48..e558d98 100644 --- a/internal/infrastructure/repository/memory/workflow_test.go +++ b/internal/infrastructure/repository/memory/workflow_test.go @@ -9,6 +9,8 @@ import ( articledomain "github.com/teradakousuke/note_maker/internal/domain/article" authordomain "github.com/teradakousuke/note_maker/internal/domain/author" 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" ) func TestPersistentWorkflowStoreRestoresDraftInputs(t *testing.T) { @@ -58,6 +60,37 @@ func TestPersistentWorkflowStoreRestoresDraftInputs(t *testing.T) { } } +func TestPersistentWorkflowStoreRestoresCustomPersonas(t *testing.T) { + path := filepath.Join(t.TempDir(), "workflow_store.json") + store, err := NewPersistentWorkflowStore(path) + if err != nil { + t.Fatalf("new store: %v", err) + } + persona := testCustomPersona() + if err := store.SavePersona(persona); err != nil { + t.Fatalf("save persona: %v", err) + } + + reopened, err := NewPersistentWorkflowStore(path) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + restored, ok := reopened.GetPersona(persona.ID) + if !ok { + t.Fatal("expected persona after reopen") + } + if restored.DisplayName != persona.DisplayName || restored.VoiceNotes.Tone != persona.VoiceNotes.Tone { + t.Fatalf("unexpected restored persona: %#v", restored) + } + listed, err := reopened.ListPersonas() + if err != nil { + t.Fatalf("list personas: %v", err) + } + if len(listed) != 1 || listed[0].ID != persona.ID { + t.Fatalf("unexpected persona list: %#v", listed) + } +} + func testAnalyzeResult(t *testing.T) authorstyle.AnalyzeResult { t.Helper() fetchedAt := time.Unix(1700000000, 0).UTC() @@ -89,6 +122,24 @@ func testAnalyzeResult(t *testing.T) authorstyle.AnalyzeResult { } } +func testCustomPersona() personadomain.Persona { + return personadomain.Persona{ + ID: "custom_writer", + DisplayName: "Custom Writer", + Description: "A locally authored test persona.", + DefaultFormat: outputformat.IDNoteArticle, + Sources: []personadomain.AuthorSource{ + {Kind: "note", Ref: "custom_writer"}, + }, + VoiceNotes: personadomain.VoiceNotes{ + FirstPerson: []string{"私"}, + Tone: "Calm, direct, and specific.", + TitlePatterns: []string{"How I use local workflows"}, + AntiPatterns: []string{"empty claims"}, + }, + } +} + func testCompletedSession(t *testing.T, profileID string) briefdomain.ArticleBriefSession { t.Helper() questions := append(briefdomain.FixedQuestions(), briefdomain.ArticleQuestion{ diff --git a/internal/infrastructure/repository/sqlite/migrations/0002_custom_personas.sql b/internal/infrastructure/repository/sqlite/migrations/0002_custom_personas.sql new file mode 100644 index 0000000..223978b --- /dev/null +++ b/internal/infrastructure/repository/sqlite/migrations/0002_custom_personas.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS custom_personas ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + default_format TEXT NOT NULL, + persona_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_custom_personas_default_format ON custom_personas(default_format); diff --git a/internal/infrastructure/repository/sqlite/workflow.go b/internal/infrastructure/repository/sqlite/workflow.go index b74912e..2f92b4e 100644 --- a/internal/infrastructure/repository/sqlite/workflow.go +++ b/internal/infrastructure/repository/sqlite/workflow.go @@ -17,6 +17,7 @@ import ( draftapp "github.com/teradakousuke/note_maker/internal/application/draft" authordomain "github.com/teradakousuke/note_maker/internal/domain/author" briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief" + personadomain "github.com/teradakousuke/note_maker/internal/domain/persona" sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source" ) @@ -551,6 +552,76 @@ ORDER BY updated_at DESC, session_id`) return briefs, nil } +// SavePersona stores a user-authored persona. +func (s *WorkflowStore) SavePersona(persona personadomain.Persona) error { + if err := persona.ValidateCustom(); err != nil { + return err + } + if strings.TrimSpace(persona.ID) == "" { + return fmt.Errorf("persona id is required") + } + personaJSON, err := marshalString(persona) + if err != nil { + return fmt.Errorf("encode persona: %w", err) + } + now := nowUTC() + _, err = s.db.Exec(` +INSERT INTO custom_personas (id, display_name, default_format, persona_json, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + default_format = excluded.default_format, + persona_json = excluded.persona_json, + updated_at = excluded.updated_at`, + persona.ID, persona.DisplayName, persona.DefaultFormat, personaJSON, formatTime(now), formatTime(now)) + if err != nil { + return fmt.Errorf("save persona: %w", err) + } + return nil +} + +// GetPersona returns a user-authored persona by ID. +func (s *WorkflowStore) GetPersona(id string) (personadomain.Persona, bool) { + var personaJSON string + err := s.db.QueryRow(`SELECT persona_json FROM custom_personas WHERE id = ?`, strings.TrimSpace(id)).Scan(&personaJSON) + if err != nil { + return personadomain.Persona{}, false + } + var persona personadomain.Persona + if err := unmarshalString(personaJSON, &persona); err != nil { + return personadomain.Persona{}, false + } + return persona, true +} + +// ListPersonas returns all user-authored personas in creation order. +func (s *WorkflowStore) ListPersonas() ([]personadomain.Persona, error) { + rows, err := s.db.Query(` +SELECT persona_json +FROM custom_personas +ORDER BY created_at, id`) + if err != nil { + return nil, fmt.Errorf("list personas: %w", err) + } + defer rows.Close() + var personas []personadomain.Persona + for rows.Next() { + var personaJSON string + if err := rows.Scan(&personaJSON); err != nil { + return nil, fmt.Errorf("scan persona: %w", err) + } + var persona personadomain.Persona + if err := unmarshalString(personaJSON, &persona); err != nil { + return nil, fmt.Errorf("decode persona: %w", err) + } + personas = append(personas, persona) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate personas: %w", err) + } + return personas, nil +} + // SaveProject stores a project aggregate. func (s *WorkflowStore) SaveProject(project ProjectRecord) error { if strings.TrimSpace(project.ID) == "" { diff --git a/internal/infrastructure/repository/sqlite/workflow_test.go b/internal/infrastructure/repository/sqlite/workflow_test.go index 00ea232..d61b529 100644 --- a/internal/infrastructure/repository/sqlite/workflow_test.go +++ b/internal/infrastructure/repository/sqlite/workflow_test.go @@ -72,6 +72,41 @@ func TestWorkflowStoreRestoresDraftInputs(t *testing.T) { } } +func TestWorkflowStoreRestoresCustomPersonas(t *testing.T) { + path := filepath.Join(t.TempDir(), "note_maker.db") + store, err := NewWorkflowStore(path) + if err != nil { + t.Fatalf("new store: %v", err) + } + persona := testCustomPersona() + if err := store.SavePersona(persona); err != nil { + t.Fatalf("save persona: %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + reopened, err := NewWorkflowStore(path) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + t.Cleanup(func() { _ = reopened.Close() }) + restored, ok := reopened.GetPersona(persona.ID) + if !ok { + t.Fatal("expected persona after reopen") + } + if restored.DisplayName != persona.DisplayName || restored.DefaultFormat != persona.DefaultFormat { + t.Fatalf("unexpected restored persona: %#v", restored) + } + listed, err := reopened.ListPersonas() + if err != nil { + t.Fatalf("list personas: %v", err) + } + if len(listed) != 1 || listed[0].ID != persona.ID { + t.Fatalf("unexpected persona list: %#v", listed) + } +} + func TestWorkflowStoreAppliesSchemaMigrations(t *testing.T) { store, err := NewWorkflowStore(filepath.Join(t.TempDir(), "note_maker.db")) if err != nil { @@ -86,7 +121,7 @@ func TestWorkflowStoreAppliesSchemaMigrations(t *testing.T) { if migrationCount != 1 { t.Fatalf("migration count = %d, want 1", migrationCount) } - for _, table := range []string{"projects", "articles", "brief_sessions", "brief_answers", "drafts", "section_regenerations", "source_selector_snapshots"} { + for _, table := range []string{"projects", "articles", "brief_sessions", "brief_answers", "briefs", "custom_personas", "drafts", "section_regenerations", "source_selector_snapshots"} { var name string if err := store.DB().QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name); err != nil { t.Fatalf("expected table %s: %v", table, err) @@ -259,6 +294,24 @@ func testAnalyzeResult(t *testing.T) authorstyleapp.AnalyzeResult { } } +func testCustomPersona() personadomain.Persona { + return personadomain.Persona{ + ID: "custom_writer", + DisplayName: "Custom Writer", + Description: "A locally authored test persona.", + DefaultFormat: outputformat.IDNoteArticle, + Sources: []personadomain.AuthorSource{ + {Kind: "note", Ref: "custom_writer"}, + }, + VoiceNotes: personadomain.VoiceNotes{ + FirstPerson: []string{"私"}, + Tone: "Calm, direct, and specific.", + TitlePatterns: []string{"How I use local workflows"}, + AntiPatterns: []string{"empty claims"}, + }, + } +} + func testCompletedSession(t *testing.T, profileID string) briefdomain.ArticleBriefSession { t.Helper() questions := append(briefdomain.FixedQuestions(), briefdomain.ArticleQuestion{ diff --git a/static/css/style.css b/static/css/style.css index f91b597..096d8ae 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -124,6 +124,59 @@ body { gap: 14px; } +.persona-field { + min-width: 0; +} + +.inline-select-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.compact-btn { + min-height: 46px; + white-space: nowrap; +} + +.persona-form { + margin: 4px 0 14px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfe; +} + +.persona-form .section-heading { + margin-top: 0; +} + +.persona-status, +.artifact-edit-status { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--muted); + background: var(--surface); + font-size: 14px; +} + +.persona-status.warning, +.artifact-edit-status.warning { + border-color: #fde68a; + color: var(--warning); + background: var(--warning-bg); +} + +.persona-status.success, +.artifact-edit-status.success { + border-color: #99f6e4; + color: var(--success); + background: var(--success-bg); +} + .mode-summary { display: grid; gap: 4px; @@ -410,6 +463,13 @@ pre { border-bottom: 1px solid var(--line); } +.artifact-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + .artifact-card-header strong { line-height: 1.35; } @@ -453,6 +513,25 @@ pre { overflow-wrap: anywhere; } +.artifact-edit-form { + display: grid; + gap: 12px; +} + +.artifact-edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.artifact-edit-form textarea { + min-height: 120px; +} + +.artifact-edit-form .full-width { + grid-column: 1 / -1; +} + .artifact-raw { color: var(--muted); font-size: 14px; @@ -715,12 +794,17 @@ pre { } .config-grid, + .artifact-edit-grid, .history-picker-grid, .history-detail, .result-grid { grid-template-columns: 1fr; } + .inline-select-action { + grid-template-columns: 1fr; + } + .section-heading, .question-config-row { display: grid; diff --git a/static/history_ui_test.go b/static/history_ui_test.go index f13634d..032f916 100644 --- a/static/history_ui_test.go +++ b/static/history_ui_test.go @@ -67,6 +67,57 @@ func TestHistoryUIContract(t *testing.T) { }) } +func TestPersonaAuthoringContract(t *testing.T) { + contract := loadStaticContract(t) + + for _, selector := range []string{ + "#add-persona-btn", + "#add-persona-form", + "#persona-id-input", + "#persona-display-name-input", + "#persona-default-format-select", + "#persona-description-input", + "#persona-first-person-input", + "#persona-source-kind-input", + "#persona-source-ref-input", + "#persona-source-url-input", + "#save-persona-btn", + "#cancel-persona-btn", + "#persona-status", + } { + assertSelectorCount(t, contract.document, selector, 1) + } + if got := strings.TrimSpace(contract.document.Find("#add-persona-btn").Text()); got != "+ Add persona" { + t.Fatalf("#add-persona-btn text = %q, want + Add persona", got) + } + if _, ok := contract.document.Find("#add-persona-form").Attr("class"); !ok { + t.Fatalf("#add-persona-form should declare a class so it can start compact/hidden") + } + + assertScriptContains(t, contract.script, []string{ + "el.addPersonaToggle.addEventListener('click', togglePersonaForm)", + "el.addPersonaForm.addEventListener('submit', createPersona)", + "el.cancelPersona.addEventListener('click', hidePersonaForm)", + "requestJSON('/api/personas', {", + "method: 'POST'", + "populatePersonaSelect()", + "populateHistoryPersonaSelect()", + "additiveEndpointStatus(error, '書き手追加APIはまだ接続されていません。バックエンド実装後に保存できます。')", + }) + assertFunctionContains(t, contract.script, "personaPayloadFromForm", []string{ + "id: slugifyPersonaId(el.personaIdInput.value || el.personaNameInput.value)", + "display_name: el.personaNameInput.value.trim()", + "description: el.personaDescriptionInput.value.trim()", + "default_format: el.personaDefaultFormatSelect.value || currentFormatId()", + "payload.voice_notes = { first_person: voice }", + "payload.sources = [{ kind: sourceKind || 'manual', ref: sourceRef, url: sourceURL }]", + }) + assertFunctionContains(t, contract.script, "upsertPersona", []string{ + "state.personas = [", + "...state.personas.filter((item) => item.id !== persona.id)", + }) +} + func TestModelSelectorConfigContract(t *testing.T) { contract := loadStaticContract(t) @@ -335,6 +386,67 @@ func TestArtifactCardsReadableContract(t *testing.T) { }) } +func TestArtifactCardEditContract(t *testing.T) { + contract := loadStaticContract(t) + + assertScriptContains(t, contract.script, []string{ + "styleEditMode: false", + "briefEditMode: false", + "'edit-brief-btn'", + "'edit-style-guide-btn'", + "PATCH", + "`/api/author-style/${encodeURIComponent(styleId)}`", + "`/api/briefs/${encodeURIComponent(sessionId)}`", + "additiveEndpointStatus(error, '文体ガイド編集APIはまだ接続されていません。内容は保存されませんでした。')", + "additiveEndpointStatus(error, '記事ブリーフ編集APIはまだ接続されていません。内容は保存されませんでした。')", + }) + assertFunctionContains(t, contract.script, "renderStyleGuideCard", []string{ + "state.currentStyleArtifact = normalized", + "if (state.styleEditMode)", + "renderStyleGuideEditForm(normalized)", + "createCardEditButton('文体ガイドを編集'", + "appendArtifactStatus(el.styleGuideCard, state.styleEditStatus, state.styleEditStatusType)", + }) + assertFunctionContains(t, contract.script, "renderBriefCard", []string{ + "if (state.briefEditMode)", + "renderBriefEditForm(brief)", + "createCardEditButton('記事ブリーフを編集'", + "appendArtifactStatus(el.briefCard, state.briefEditStatus, state.briefEditStatusType)", + }) + assertFunctionContains(t, contract.script, "renderBriefEditForm", []string{ + "form.id = 'brief-edit-form'", + "['theme', 'テーマ', 'input']", + "['reader', '読者', 'textarea']", + "['must_include', '必ず含めること', 'textarea']", + "save.id = 'save-brief-edit-btn'", + "cancel.id = 'cancel-brief-edit-btn'", + "form.addEventListener('submit', (event) => saveBriefEdit(event, brief))", + }) + assertFunctionContains(t, contract.script, "saveBriefEdit", []string{ + "method: 'PATCH'", + "body: { fields }", + "state.completedBrief = { ...originalBrief, ...updatedBrief }", + "el.briefPreview.textContent = JSON.stringify(state.completedBrief, null, 2)", + "setBriefEditStatus('記事ブリーフを保存しました。', 'success')", + }) + assertFunctionContains(t, contract.script, "saveStyleGuideEdit", []string{ + "method: 'PATCH'", + "guide_markdown: markdown", + "setStyleEditStatus('文体ガイドを保存しました。', 'success')", + "el.guidePreview.textContent = styleGuideMarkdown(updated)", + }) + assertFunctionContains(t, contract.script, "renderStyleGuideEditForm", []string{ + "form.id = 'style-guide-edit-form'", + "'style-guide-markdown-input'", + "save.id = 'save-style-guide-edit-btn'", + "cancel.id = 'cancel-style-guide-edit-btn'", + }) + assertFunctionContains(t, contract.script, "artifactStatusIdForContainer", []string{ + "return 'brief-edit-status'", + "return 'style-guide-edit-status'", + }) +} + func loadStaticContract(t *testing.T) staticContract { t.Helper() diff --git a/static/index.html b/static/index.html index 7ab96e6..b42902e 100644 --- a/static/index.html +++ b/static/index.html @@ -30,9 +30,12 @@

設定

-
+
- +
+ + +
@@ -55,6 +58,50 @@

設定

+

保存方式

diff --git a/static/js/script.js b/static/js/script.js index 3f9e068..f1672ab 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -68,11 +68,33 @@ document.addEventListener('DOMContentLoaded', () => { answerAbortController: null, draftAbortController: null, pendingSectionReplacement: null, + currentStyleArtifact: null, + styleEditMode: false, + styleEditStatus: '', + styleEditStatusType: '', + briefEditMode: false, + briefEditStatus: '', + briefEditStatusType: '', + personaCreateStatus: '', + personaCreateStatusType: '', }; const el = { modelStatus: document.getElementById('model-status'), personaSelect: document.getElementById('persona-select'), + addPersonaToggle: document.getElementById('add-persona-btn'), + addPersonaForm: document.getElementById('add-persona-form'), + personaIdInput: document.getElementById('persona-id-input'), + personaNameInput: document.getElementById('persona-display-name-input'), + personaDefaultFormatSelect: document.getElementById('persona-default-format-select'), + personaDescriptionInput: document.getElementById('persona-description-input'), + personaVoiceInput: document.getElementById('persona-first-person-input'), + personaSourceKindInput: document.getElementById('persona-source-kind-input'), + personaSourceRefInput: document.getElementById('persona-source-ref-input'), + personaSourceURLInput: document.getElementById('persona-source-url-input'), + savePersona: document.getElementById('save-persona-btn'), + cancelPersona: document.getElementById('cancel-persona-btn'), + personaStatus: document.getElementById('persona-status'), formatSelect: document.getElementById('format-select'), modeSummary: document.getElementById('mode-summary'), styleModel: document.getElementById('style-model'), @@ -151,6 +173,9 @@ document.addEventListener('DOMContentLoaded', () => { loadStorageConfig(); el.personaSelect.addEventListener('change', onPersonaChange); + el.addPersonaToggle.addEventListener('click', togglePersonaForm); + el.addPersonaForm.addEventListener('submit', createPersona); + el.cancelPersona.addEventListener('click', hidePersonaForm); el.formatSelect.addEventListener('change', onFormatChange); el.styleModel.addEventListener('change', saveModelConfig); el.briefModel.addEventListener('change', saveModelConfig); @@ -202,6 +227,7 @@ document.addEventListener('DOMContentLoaded', () => { state.formats = formats; populatePersonaSelect(); populateFormatSelect(); + populatePersonaDefaultFormatSelect(); populateHistoryPersonaSelect(); applyPersonaDefaults(false); renderModeSummary(); @@ -275,12 +301,14 @@ document.addEventListener('DOMContentLoaded', () => { } function applyStyleResult(data) { - state.profileId = data.profile_id; - el.profileId.textContent = data.profile_id; - el.guideId.textContent = data.guide_id; - el.articleCount.textContent = String(data.article_count); - el.guidePreview.textContent = data.guide_markdown; - renderStyleGuideCard(data); + const normalized = normalizeHistoryStyle(data); + state.currentStyleArtifact = normalized; + state.profileId = normalized.profileId || normalized.id; + el.profileId.textContent = state.profileId; + el.guideId.textContent = normalized.guideId; + el.articleCount.textContent = normalized.articleCount === undefined ? '' : String(normalized.articleCount); + el.guidePreview.textContent = styleGuideMarkdown(normalized); + renderStyleGuideCard(normalized); el.styleResult.classList.remove('hidden'); el.startInterview.disabled = false; } @@ -406,6 +434,7 @@ document.addEventListener('DOMContentLoaded', () => { renderTranscript(data); if (data.completed) { state.completedBrief = data.brief; + state.briefEditMode = false; state.nextQuestion = null; el.briefPreview.textContent = JSON.stringify(data.brief, null, 2); renderBriefCard(data.brief); @@ -789,6 +818,20 @@ document.addEventListener('DOMContentLoaded', () => { }); } + function populatePersonaDefaultFormatSelect() { + el.personaDefaultFormatSelect.innerHTML = ''; + state.formats.forEach((format) => { + const option = document.createElement('option'); + option.value = format.id; + option.textContent = format.display_name; + option.selected = format.id === currentFormatId(); + el.personaDefaultFormatSelect.appendChild(option); + }); + if (!el.personaDefaultFormatSelect.value && state.formats[0]) { + el.personaDefaultFormatSelect.value = state.formats[0].id; + } + } + function populateHistoryPersonaSelect() { el.historyPersonaSelect.innerHTML = ''; state.personas.forEach((persona) => { @@ -803,6 +846,139 @@ document.addEventListener('DOMContentLoaded', () => { } } + function togglePersonaForm() { + const willShow = el.addPersonaForm.classList.contains('hidden'); + el.addPersonaForm.classList.toggle('hidden', !willShow); + if (willShow) { + resetPersonaForm(); + el.personaNameInput.focus(); + } + } + + function hidePersonaForm() { + el.addPersonaForm.classList.add('hidden'); + resetPersonaForm(); + } + + function resetPersonaForm() { + el.personaIdInput.value = ''; + el.personaNameInput.value = ''; + el.personaDescriptionInput.value = ''; + el.personaVoiceInput.value = ''; + el.personaSourceKindInput.value = ''; + el.personaSourceRefInput.value = ''; + el.personaSourceURLInput.value = ''; + populatePersonaDefaultFormatSelect(); + setPersonaStatus('IDと表示名だけで追加できます。ソースは後から文体ソース欄で変更できます。'); + } + + async function createPersona(event) { + event.preventDefault(); + clearError(); + const payload = personaPayloadFromForm(); + if (!payload.display_name) { + setPersonaStatus('表示名を入力してください。', 'warning'); + el.personaNameInput.focus(); + return; + } + if (!payload.id) { + setPersonaStatus('IDを入力してください。', 'warning'); + el.personaIdInput.focus(); + return; + } + el.savePersona.disabled = true; + setPersonaStatus('書き手を保存しています...'); + try { + const data = await requestJSON('/api/personas', { + method: 'POST', + body: payload, + }); + const persona = normalizePersonaForSelect({ ...payload, ...data }); + upsertPersona(persona); + config.mode.persona = persona.id; + config.mode.format = persona.default_format || config.mode.format; + saveConfig(); + populatePersonaSelect(); + populateHistoryPersonaSelect(); + if (persona.default_format && state.formats.some((format) => format.id === persona.default_format)) { + el.formatSelect.value = persona.default_format; + } + applyPersonaDefaults(false); + applyStyleSourceDefault(true); + renderModeSummary(); + await loadQuestionTemplate(); + await loadWorkflowHistory(); + setPersonaStatus('書き手を追加しました。現在の書き手として選択しています。', 'success'); + } catch (error) { + const message = additiveEndpointStatus(error, '書き手追加APIはまだ接続されていません。バックエンド実装後に保存できます。'); + setPersonaStatus(message, 'warning'); + } finally { + el.savePersona.disabled = false; + } + } + + function personaPayloadFromForm() { + const sourceKind = el.personaSourceKindInput.value.trim(); + const sourceRef = el.personaSourceRefInput.value.trim(); + const sourceURL = el.personaSourceURLInput.value.trim(); + const voice = el.personaVoiceInput.value.split(/[、,/]/).map((item) => item.trim()).filter(Boolean); + const payload = { + id: slugifyPersonaId(el.personaIdInput.value || el.personaNameInput.value), + display_name: el.personaNameInput.value.trim(), + description: el.personaDescriptionInput.value.trim(), + default_format: el.personaDefaultFormatSelect.value || currentFormatId(), + }; + if (voice.length) { + payload.voice_notes = { first_person: voice }; + payload.voice = voice.join(' / '); + } + if (sourceKind || sourceRef || sourceURL) { + payload.sources = [{ kind: sourceKind || 'manual', ref: sourceRef, url: sourceURL }]; + payload.source = sourceRef || sourceURL; + payload.source_kind = sourceKind || 'manual'; + payload.source_ref = sourceRef; + payload.source_url = sourceURL; + } + return payload; + } + + function slugifyPersonaId(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, ''); + } + + function normalizePersonaForSelect(persona = {}) { + return { + ...persona, + id: String(persona.id || persona.ID || '').trim(), + display_name: persona.display_name || persona.displayName || persona.name || persona.Name || persona.id || '', + description: persona.description || persona.Description || '', + default_format: persona.default_format || persona.defaultFormat || persona.DefaultFormat || '', + voice_notes: persona.voice_notes || persona.voiceNotes || persona.VoiceNotes || {}, + sources: arrayFrom(persona.sources || persona.Sources), + }; + } + + function upsertPersona(persona) { + if (!persona.id) { + return; + } + state.personas = [ + persona, + ...state.personas.filter((item) => item.id !== persona.id), + ]; + } + + function setPersonaStatus(message, type = '') { + state.personaCreateStatus = message; + state.personaCreateStatusType = type; + el.personaStatus.className = `persona-status${type ? ` ${type}` : ''}`; + el.personaStatus.textContent = message; + } + function onPersonaChange() { config.mode.persona = currentPersonaId(); applyPersonaDefaults(true); @@ -816,6 +992,9 @@ document.addEventListener('DOMContentLoaded', () => { function onFormatChange() { config.mode.format = currentFormatId(); + if (el.addPersonaForm.classList.contains('hidden') === false) { + populatePersonaDefaultFormatSelect(); + } applyStyleSourceDefault(true); saveConfig(); renderModeSummary(); @@ -968,6 +1147,14 @@ document.addEventListener('DOMContentLoaded', () => { return `履歴の取得に失敗しました: ${message}`; } + function additiveEndpointStatus(error, fallback) { + const message = error.message || ''; + if (message.includes('HTTP 404') || message.includes('HTTP 501')) { + return fallback; + } + return `保存に失敗しました: ${message}`; + } + async function selectHistoryStyle() { state.selectedHistoryStyle = findHistoryStyle(el.historyStyleSelect.value); el.openHistory.disabled = !historySelectionReady(); @@ -979,6 +1166,8 @@ document.addEventListener('DOMContentLoaded', () => { try { const detail = await loadHistoryStyleDetail(state.selectedHistoryStyle); state.selectedHistoryStyle = detail; + state.currentStyleArtifact = normalizeHistoryStyle(detail); + state.styleEditMode = false; renderStyleGuideCard(detail); el.guidePreview.textContent = styleGuideMarkdown(detail); el.styleResult.classList.remove('hidden'); @@ -1001,6 +1190,8 @@ document.addEventListener('DOMContentLoaded', () => { const detail = await loadHistorySessionDetail(state.selectedHistorySession); state.selectedHistorySession = detail; if (detail.brief) { + state.completedBrief = detail.brief; + state.briefEditMode = false; renderBriefCard(detail.brief); el.briefPreview.textContent = JSON.stringify(detail.brief, null, 2); el.briefResult.classList.remove('hidden'); @@ -1249,6 +1440,8 @@ document.addEventListener('DOMContentLoaded', () => { function applyHistoryStyle(item) { const data = normalizeHistoryStyle(item); + state.currentStyleArtifact = data; + state.styleEditMode = false; state.profileId = data.profileId || data.id; el.profileId.textContent = state.profileId; el.guideId.textContent = data.guideId || ''; @@ -1279,6 +1472,7 @@ document.addEventListener('DOMContentLoaded', () => { state.answers = data.answers || []; state.nextQuestion = data.nextQuestion || null; state.completedBrief = data.completed ? data.brief : null; + state.briefEditMode = false; rememberQuestions(data.questions || state.templateQuestions); rememberQuestion(data.nextQuestion); el.interviewArea.classList.remove('hidden'); @@ -1320,6 +1514,7 @@ document.addEventListener('DOMContentLoaded', () => { await loadQuestionTemplate(); if (data.brief) { state.completedBrief = data.brief; + state.briefEditMode = false; renderBriefCard(data.brief); el.briefPreview.textContent = JSON.stringify(data.brief, null, 2); el.briefResult.classList.remove('hidden'); @@ -2249,12 +2444,18 @@ document.addEventListener('DOMContentLoaded', () => { el.styleGuideCard.innerHTML = ''; const markdown = styleGuideMarkdown(data); if (!data || !markdown) { + state.currentStyleArtifact = null; el.styleGuideCard.className = 'artifact-card empty'; el.styleGuideCard.textContent = '文体ガイドはまだありません。文体分析または保存済み履歴から選択してください。'; return; } const normalized = normalizeHistoryStyle(data); + state.currentStyleArtifact = normalized; el.styleGuideCard.className = 'artifact-card'; + if (state.styleEditMode) { + renderStyleGuideEditForm(normalized); + return; + } el.styleGuideCard.appendChild(createArtifactHeader( normalized.title || '文体ガイド', [ @@ -2262,7 +2463,14 @@ document.addEventListener('DOMContentLoaded', () => { ['Guide', normalized.guideId], ['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)], ], + createCardEditButton('文体ガイドを編集', () => { + state.styleEditMode = true; + state.styleEditStatus = ''; + state.styleEditStatusType = ''; + renderStyleGuideCard(state.currentStyleArtifact); + }, 'edit-style-guide-btn'), )); + appendArtifactStatus(el.styleGuideCard, state.styleEditStatus, state.styleEditStatusType); const sections = markdownSectionsForCard(markdown); if (sections.length) { sections.slice(0, 5).forEach((section) => { @@ -2281,6 +2489,10 @@ document.addEventListener('DOMContentLoaded', () => { return; } el.briefCard.className = 'artifact-card'; + if (state.briefEditMode) { + renderBriefEditForm(brief); + return; + } const theme = briefField(brief, 'theme', 'Theme') || '記事ブリーフ'; el.briefCard.appendChild(createArtifactHeader( theme, @@ -2289,7 +2501,14 @@ document.addEventListener('DOMContentLoaded', () => { ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')], ['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')], ], + createCardEditButton('記事ブリーフを編集', () => { + state.briefEditMode = true; + state.briefEditStatus = ''; + state.briefEditStatusType = ''; + renderBriefCard(state.completedBrief || brief); + }, 'edit-brief-btn'), )); + appendArtifactStatus(el.briefCard, state.briefEditStatus, state.briefEditStatusType); [ ['読者', briefField(brief, 'reader', 'Reader')], ['冒頭の具体例', briefField(brief, 'opening_episode', 'OpeningEpisode')], @@ -2312,11 +2531,274 @@ document.addEventListener('DOMContentLoaded', () => { } } - function createArtifactHeader(title, metaItems) { + function renderStyleGuideEditForm(style) { + const form = document.createElement('form'); + form.id = 'style-guide-edit-form'; + form.className = 'artifact-edit-form'; + form.appendChild(createArtifactHeader( + '文体ガイドを編集', + [ + ['Profile', style.profileId || style.id], + ['Guide', style.guideId], + ], + )); + + const markdownInput = createArtifactEditControl('style-guide-markdown-input', 'Markdown', styleGuideMarkdown(style), 'textarea'); + markdownInput.wrapper.classList.add('full-width'); + markdownInput.control.rows = 12; + const grid = document.createElement('div'); + grid.className = 'artifact-edit-grid'; + grid.append(markdownInput.wrapper); + + const actions = document.createElement('div'); + actions.className = 'edit-actions'; + const save = document.createElement('button'); + save.id = 'save-style-guide-edit-btn'; + save.type = 'submit'; + save.className = 'primary-btn'; + save.textContent = '保存'; + const cancel = document.createElement('button'); + cancel.id = 'cancel-style-guide-edit-btn'; + cancel.type = 'button'; + cancel.className = 'secondary-btn'; + cancel.textContent = 'キャンセル'; + cancel.addEventListener('click', () => { + state.styleEditMode = false; + renderStyleGuideCard(state.currentStyleArtifact); + }); + actions.append(save, cancel); + form.append(grid, actions); + appendArtifactStatus(form, state.styleEditStatus, state.styleEditStatusType); + form.addEventListener('submit', (event) => saveStyleGuideEdit(event, { + guide_markdown: markdownInput.control.value, + })); + el.styleGuideCard.appendChild(form); + } + + function renderBriefEditForm(brief) { + const form = document.createElement('form'); + form.id = 'brief-edit-form'; + form.className = 'artifact-edit-form'; + form.appendChild(createArtifactHeader( + '記事ブリーフを編集', + [ + ['Session', state.sessionId || briefField(brief, 'session_id', 'SessionID')], + ['Persona', briefField(brief, 'persona_id', 'PersonaID')], + ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')], + ], + )); + + const controls = [ + ['theme', 'テーマ', 'input'], + ['reader', '読者', 'textarea'], + ['opening_episode', '冒頭の具体例', 'textarea'], + ['expected_reader_action', '読後アクション', 'textarea'], + ['must_include', '必ず含めること', 'textarea'], + ['tone_stance', 'トーンと立場', 'textarea'], + ].map(([field, label, type]) => { + const item = createArtifactEditControl(`brief-${field}-input`, label, briefField(brief, field, snakeToPascal(field)), type); + item.control.dataset.field = field; + if (type === 'textarea') { + item.control.rows = 3; + } + return item; + }); + const grid = document.createElement('div'); + grid.className = 'artifact-edit-grid'; + controls.forEach((item) => grid.appendChild(item.wrapper)); + + const actions = document.createElement('div'); + actions.className = 'edit-actions'; + const save = document.createElement('button'); + save.id = 'save-brief-edit-btn'; + save.type = 'submit'; + save.className = 'primary-btn'; + save.textContent = '保存'; + const cancel = document.createElement('button'); + cancel.id = 'cancel-brief-edit-btn'; + cancel.type = 'button'; + cancel.className = 'secondary-btn'; + cancel.textContent = 'キャンセル'; + cancel.addEventListener('click', () => { + state.briefEditMode = false; + renderBriefCard(state.completedBrief || brief); + }); + actions.append(save, cancel); + form.append(grid, actions); + appendArtifactStatus(form, state.briefEditStatus, state.briefEditStatusType); + form.addEventListener('submit', (event) => saveBriefEdit(event, brief)); + el.briefCard.appendChild(form); + } + + async function saveStyleGuideEdit(event, fields) { + event.preventDefault(); + clearError(); + const markdown = String(fields.guide_markdown || '').trim(); + const styleId = state.currentStyleArtifact?.id || state.profileId; + if (!styleId) { + setStyleEditStatus('保存する文体ガイドIDがありません。', 'warning'); + return; + } + if (!markdown) { + setStyleEditStatus('Markdownを入力してください。', 'warning'); + return; + } + setStyleEditStatus('文体ガイドを保存しています...'); + try { + const data = await requestJSON(`/api/author-style/${encodeURIComponent(styleId)}`, { + method: 'PATCH', + body: { + guide_markdown: markdown, + markdown, + profile_id: state.currentStyleArtifact?.profileId || state.profileId, + guide_id: state.currentStyleArtifact?.guideId || '', + persona_id: currentPersonaId(), + output_format_id: currentFormatId(), + }, + }); + const updated = normalizeHistoryStyle({ ...state.currentStyleArtifact, ...data, guide_markdown: data.guide_markdown || data.guideMarkdown || markdown }); + state.currentStyleArtifact = updated; + state.profileId = updated.profileId || updated.id || state.profileId; + el.profileId.textContent = state.profileId; + el.guideId.textContent = updated.guideId || ''; + el.articleCount.textContent = updated.articleCount === undefined ? '' : String(updated.articleCount); + el.guidePreview.textContent = styleGuideMarkdown(updated); + state.styleEditMode = false; + setStyleEditStatus('文体ガイドを保存しました。', 'success'); + renderStyleGuideCard(updated); + } catch (error) { + setStyleEditStatus(additiveEndpointStatus(error, '文体ガイド編集APIはまだ接続されていません。内容は保存されませんでした。'), 'warning'); + renderStyleGuideCard(state.currentStyleArtifact); + } + } + + async function saveBriefEdit(event, originalBrief) { + event.preventDefault(); + clearError(); + const sessionId = state.sessionId || briefField(originalBrief, 'session_id', 'SessionID') || briefField(originalBrief, 'brief_session_id', 'BriefSessionID'); + if (!sessionId) { + setBriefEditStatus('保存する取材セッションIDがありません。', 'warning'); + return; + } + const fields = {}; + event.currentTarget.querySelectorAll('[data-field]').forEach((control) => { + fields[control.dataset.field] = control.value.trim(); + }); + if (!fields.theme) { + setBriefEditStatus('テーマを入力してください。', 'warning'); + return; + } + setBriefEditStatus('記事ブリーフを保存しています...'); + try { + const data = await requestJSON(`/api/briefs/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: { fields }, + }); + const artifact = data.brief || data.Brief || data; + const updatedBrief = artifact.brief || artifact.Brief || artifact; + state.completedBrief = { ...originalBrief, ...updatedBrief }; + el.briefPreview.textContent = JSON.stringify(state.completedBrief, null, 2); + state.briefEditMode = false; + setBriefEditStatus('記事ブリーフを保存しました。', 'success'); + renderBriefCard(state.completedBrief); + el.generateDraft.disabled = !state.profileId; + } catch (error) { + setBriefEditStatus(additiveEndpointStatus(error, '記事ブリーフ編集APIはまだ接続されていません。内容は保存されませんでした。'), 'warning'); + renderBriefCard(originalBrief); + } + } + + function createArtifactEditControl(id, label, value, type = 'input') { + const wrapper = document.createElement('div'); + wrapper.className = 'field-row'; + const labelElement = document.createElement('label'); + labelElement.htmlFor = id; + labelElement.textContent = label; + const control = type === 'textarea' ? document.createElement('textarea') : document.createElement('input'); + control.id = id; + if (type !== 'textarea') { + control.type = 'text'; + } + control.value = value || ''; + wrapper.append(labelElement, control); + return { wrapper, control }; + } + + function createCardEditButton(label, onClick, id = '') { + const button = document.createElement('button'); + if (id) { + button.id = id; + } + button.type = 'button'; + button.className = 'secondary-btn compact-btn'; + button.textContent = '編集'; + button.setAttribute('aria-label', label); + button.addEventListener('click', onClick); + return button; + } + + function appendArtifactStatus(container, message, type = '') { + if (!message) { + return; + } + const status = document.createElement('div'); + status.id = artifactStatusIdForContainer(container); + status.className = `artifact-edit-status${type ? ` ${type}` : ''}`; + status.textContent = message; + container.appendChild(status); + } + + function setStyleEditStatus(message, type = '') { + state.styleEditStatus = message; + state.styleEditStatusType = type; + updateArtifactStatusElement(el.styleGuideCard, message, type); + } + + function setBriefEditStatus(message, type = '') { + state.briefEditStatus = message; + state.briefEditStatusType = type; + updateArtifactStatusElement(el.briefCard, message, type); + } + + function updateArtifactStatusElement(container, message, type = '') { + if (!container) { + return; + } + let status = container.querySelector('.artifact-edit-status'); + if (!message) { + status?.remove(); + return; + } + if (!status) { + status = document.createElement('div'); + status.id = artifactStatusIdForContainer(container); + container.appendChild(status); + } + status.className = `artifact-edit-status${type ? ` ${type}` : ''}`; + status.textContent = message; + } + + function artifactStatusIdForContainer(container) { + if (container === el.briefCard || container?.id === 'brief-edit-form') { + return 'brief-edit-status'; + } + if (container === el.styleGuideCard || container?.id === 'style-guide-edit-form') { + return 'style-guide-edit-status'; + } + return ''; + } + + function createArtifactHeader(title, metaItems, action = null) { const header = document.createElement('div'); header.className = 'artifact-card-header'; + const titleRow = document.createElement('div'); + titleRow.className = 'artifact-title-row'; const titleElement = document.createElement('strong'); titleElement.textContent = title; + titleRow.appendChild(titleElement); + if (action) { + titleRow.appendChild(action); + } const meta = document.createElement('div'); meta.className = 'artifact-meta'; metaItems.filter(([, value]) => value !== undefined && value !== null && String(value).trim()).forEach(([label, value]) => { @@ -2324,7 +2806,7 @@ document.addEventListener('DOMContentLoaded', () => { item.textContent = `${label}: ${value}`; meta.appendChild(item); }); - header.append(titleElement, meta); + header.append(titleRow, meta); return header; } @@ -2399,6 +2881,12 @@ document.addEventListener('DOMContentLoaded', () => { return String(value || '').replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } + function snakeToPascal(value) { + return String(value || '').split('_').filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + } + function arrayFrom(value) { return Array.isArray(value) ? value : []; } diff --git a/tests/e2e/test_history_stream_regenerate.py b/tests/e2e/test_history_stream_regenerate.py index 52734bc..bac2dc9 100644 --- a/tests/e2e/test_history_stream_regenerate.py +++ b/tests/e2e/test_history_stream_regenerate.py @@ -242,11 +242,12 @@ def calls_to(self, path: str) -> list[dict[str, Any]]: return [call for call in self.calls if call["path"] == path] -def install_routes(page: Page, handlers: dict[str, Any] | None = None) -> StubState: +def install_routes(page: Page, handlers: dict[str, Any] | None = None, *, clear_storage: bool = True) -> StubState: state = StubState() handlers = handlers or {} - page.add_init_script("localStorage.clear();") + if clear_storage: + page.add_init_script("localStorage.clear();") page.route( "https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js", lambda route: route.fulfill( diff --git a/tests/e2e/test_phase_c_persona_history_polish.py b/tests/e2e/test_phase_c_persona_history_polish.py new file mode 100644 index 0000000..812632a --- /dev/null +++ b/tests/e2e/test_phase_c_persona_history_polish.py @@ -0,0 +1,242 @@ +import re +from typing import Any + +from playwright.sync_api import Page, Route, Request, expect + +from test_history_stream_regenerate import ( + BRIEF, + HISTORY_INDEX, + PERSONAS, + QUESTIONS, + SESSION_DETAIL, + STYLE_DETAIL, + StubState, + fulfill_json, + install_routes, + open_app, + open_history_session, + select_when_enabled, +) + + +PHASE_C_PERSONA = { + "id": "phase-c-writer", + "display_name": "Phase C Writer", + "description": "Persona added from browser E2E", + "default_format": "note_article", + "voice_notes": {"first_person": ["私"], "tone": "direct"}, + "sources": [{"kind": "note", "ref": "phase-c-feed"}], +} + +PHASE_C_STYLE = { + **STYLE_DETAIL, + "profile_id": "phase-c-style", + "guide_id": "phase-c-guide", + "title": "Phase C 文体ガイド", + "persona_id": "phase-c-writer", + "guide_markdown": "# Phase C 文体ガイド\n\n- 追加ペルソナの履歴です", +} + +PHASE_C_SESSION = { + **SESSION_DETAIL, + "session_id": "phase-c-session", + "title": "Phase C 取材セッション", + "style_profile_id": "phase-c-style", + "persona_id": "phase-c-writer", + "brief": { + **BRIEF, + "theme": "追加ペルソナの履歴を開く", + "persona_id": "phase-c-writer", + "style_profile_id": "phase-c-style", + }, + "questions": QUESTIONS, +} + +PHASE_C_HISTORY_INDEX = { + "style_guides": [ + { + "profile_id": "phase-c-style", + "title": "Phase C 文体ガイド", + "persona_id": "phase-c-writer", + "output_format_id": "note_article", + "updated_at": "2026-05-03T08:00:00Z", + } + ], + "sessions": [ + { + "session_id": "phase-c-session", + "title": "Phase C 取材セッション", + "style_profile_id": "phase-c-style", + "persona_id": "phase-c-writer", + "output_format_id": "note_article", + "completed": True, + "updated_at": "2026-05-03T08:00:00Z", + } + ], + "projects": [], + "articles": [], + "drafts": [], +} + + +def phase_c_locator(page: Page, *selectors: str): + for selector in selectors: + if page.locator(selector).count(): + return page.locator(selector) + return page.locator(selectors[0]) + + +def test_add_persona_updates_current_and_history_selectors_after_reload(page: Page, base_url: str) -> None: + personas = [*PERSONAS] + + def personas_handler(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None: + if call["method"] == "GET": + fulfill_json(route, personas) + return None + assert call["method"] == "POST" + payload = call["payload"] + assert payload["id"] == PHASE_C_PERSONA["id"] + assert payload["display_name"] == PHASE_C_PERSONA["display_name"] + assert payload["default_format"] == "note_article" + assert payload["sources"][0]["ref"] == "phase-c-feed" + personas.append(PHASE_C_PERSONA) + fulfill_json(route, PHASE_C_PERSONA, status=201) + return None + + def history_handler(route: Route, request: Request, call: dict[str, Any], state: StubState) -> dict[str, Any]: + persona_id = call["query"].get("persona_id", [""])[0] + return PHASE_C_HISTORY_INDEX if persona_id == "phase-c-writer" else HISTORY_INDEX + + state = install_routes( + page, + { + "/api/personas": personas_handler, + "/api/workflow/artifacts": history_handler, + "/api/author-style/phase-c-style": lambda *_: PHASE_C_STYLE, + "/api/brief-sessions/phase-c-session": lambda *_: PHASE_C_SESSION, + }, + clear_storage=False, + ) + open_app(page, base_url) + + phase_c_locator(page, "#add-persona-toggle-btn", "#add-persona-btn").click() + + expect(page.locator("#add-persona-form")).to_be_visible() + page.locator("#persona-id-input").fill("phase-c-writer") + phase_c_locator(page, "#persona-name-input", "#persona-display-name-input").fill("Phase C Writer") + page.locator("#persona-description-input").fill("Persona added from browser E2E") + page.locator("#persona-default-format-select").select_option("note_article") + phase_c_locator(page, "#persona-voice-input", "#persona-first-person-input").fill("私") + page.locator("#persona-source-kind-input").fill("note") + page.locator("#persona-source-ref-input").fill("phase-c-feed") + page.locator("#save-persona-btn").click() + + expect(page.locator("#persona-select")).to_have_value("phase-c-writer") + expect(page.locator("#history-persona-select")).to_have_value("phase-c-writer") + expect(page.locator("#history-status")).to_contain_text("1件の文体ガイド") + select_when_enabled(page, "#history-style-select", "phase-c-style") + select_when_enabled(page, "#history-session-select", "phase-c-session") + expect(page.locator("#brief-card")).to_contain_text("追加ペルソナの履歴を開く") + + page.reload() + + expect(page.locator('#persona-select option[value="phase-c-writer"]')).to_have_text("Phase C Writer") + expect(page.locator('#history-persona-select option[value="phase-c-writer"]')).to_have_text("Phase C Writer") + page.locator("#persona-select").select_option("phase-c-writer") + expect(page.locator("#history-persona-select")).to_have_value("phase-c-writer") + expect(page.locator("#history-status")).to_contain_text("1件の文体ガイド") + assert state.calls_to("/api/personas")[0]["method"] == "GET" + assert any(call["method"] == "POST" for call in state.calls_to("/api/personas")) + + +def test_saved_history_brief_card_edit_save_cancel_and_error(page: Page, base_url: str) -> None: + patch_calls = 0 + updated_brief = { + **BRIEF, + "theme": "保存後のブリーフテーマ", + "reader": "保存状態を確認する編集者", + } + + def patch_brief(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None: + nonlocal patch_calls + assert call["method"] == "PATCH" + patch_calls += 1 + if patch_calls == 1: + assert call["payload"]["fields"]["theme"] == updated_brief["theme"] + fulfill_json(route, updated_brief) + return None + fulfill_json(route, {"error": {"message": "brief save failed"}}, status=500) + return None + + state = install_routes(page, {"/api/briefs/session-history": patch_brief}) + open_app(page, base_url) + open_history_session(page) + + page.locator("#edit-brief-btn").click() + expect(page.locator("#brief-edit-form")).to_be_visible() + page.locator("#brief-theme-input").fill("キャンセルされるテーマ") + page.locator("#cancel-brief-edit-btn").click() + expect(page.locator("#brief-card")).to_contain_text("履歴から再開する記事") + expect(page.locator("#brief-card")).not_to_contain_text("キャンセルされるテーマ") + + page.locator("#edit-brief-btn").click() + page.locator("#brief-theme-input").fill(updated_brief["theme"]) + page.locator("#brief-reader-input").fill(updated_brief["reader"]) + page.locator("#save-brief-edit-btn").click() + expect(page.locator("#brief-card")).to_contain_text(updated_brief["theme"]) + expect(page.locator("#brief-card")).to_contain_text(updated_brief["reader"]) + expect(page.locator("#brief-edit-status")).to_contain_text(re.compile("保存")) + + page.locator("#edit-brief-btn").click() + page.locator("#brief-theme-input").fill("失敗するブリーフテーマ") + page.locator("#save-brief-edit-btn").click() + expect(page.locator("#brief-edit-status")).to_contain_text(re.compile("失敗|failed|error", re.I)) + expect(page.locator("#brief-card")).not_to_contain_text("失敗するブリーフテーマ") + assert len(state.calls_to("/api/briefs/session-history")) == 2 + + +def test_saved_history_style_card_edit_save_cancel_and_error(page: Page, base_url: str) -> None: + patch_calls = 0 + updated_style = { + **STYLE_DETAIL, + "guide_markdown": "# 保存後の文体ガイド\n\n- 保存された編集内容です", + } + + def style_detail(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None: + nonlocal patch_calls + if call["method"] == "GET": + fulfill_json(route, STYLE_DETAIL) + return None + assert call["method"] == "PATCH" + patch_calls += 1 + if patch_calls == 1: + assert "保存された編集内容" in call["payload"]["guide_markdown"] + fulfill_json(route, updated_style) + return None + fulfill_json(route, {"error": {"message": "style save failed"}}, status=500) + return None + + state = install_routes(page, {"/api/author-style/style-history": style_detail}) + open_app(page, base_url) + select_when_enabled(page, "#history-style-select", "style-history") + expect(page.locator("#style-guide-card")).to_contain_text("具体例から始める") + + page.locator("#edit-style-guide-btn").click() + expect(page.locator("#style-guide-edit-form")).to_be_visible() + page.locator("#style-guide-markdown-input").fill("# キャンセルされる文体ガイド\n\n- 保存しない") + page.locator("#cancel-style-guide-edit-btn").click() + expect(page.locator("#style-guide-card")).to_contain_text("履歴文体ガイド") + expect(page.locator("#style-guide-card")).not_to_contain_text("キャンセルされる文体ガイド") + + page.locator("#edit-style-guide-btn").click() + page.locator("#style-guide-markdown-input").fill(updated_style["guide_markdown"]) + page.locator("#save-style-guide-edit-btn").click() + expect(page.locator("#style-guide-card")).to_contain_text("保存された編集内容") + expect(page.locator("#style-guide-edit-status")).to_contain_text(re.compile("保存")) + + page.locator("#edit-style-guide-btn").click() + page.locator("#style-guide-markdown-input").fill("# 失敗する文体ガイド\n\n- 保存しない") + page.locator("#save-style-guide-edit-btn").click() + expect(page.locator("#style-guide-edit-status")).to_contain_text(re.compile("失敗|failed|error", re.I)) + expect(page.locator("#style-guide-card")).not_to_contain_text("失敗する文体ガイド") + assert len([call for call in state.calls_to("/api/author-style/style-history") if call["method"] == "PATCH"]) == 2