From 17ef2af2a6e864fa5966d6084bc35aa73edaf758 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sat, 2 May 2026 12:30:32 +0900
Subject: [PATCH 01/33] Add persona and format draft strategies
Closes #21
Refs #23 #24 #25
---
cmd/server/main.go | 3 +
...02-multi-persona-multi-format-extension.md | 22 +-
.../multi-persona-multi-format.md | 27 +-
docs/research/publishing-platforms.md | 116 ++++++
internal/application/brief/service.go | 4 +-
internal/application/draft/format_guides.go | 32 ++
.../draft/format_guides/homepage_section.md | 26 ++
.../draft/format_guides/markdown_blog.md | 53 +++
.../application/draft/format_guides/note.md | 36 ++
.../application/draft/format_guides/qiita.md | 42 +++
.../application/draft/format_guides/zenn.md | 42 +++
internal/application/draft/prompt.go | 97 ++++-
internal/application/draft/service.go | 19 +-
internal/application/draft/service_test.go | 115 ++++++
internal/application/draft/types.go | 4 +
internal/domain/article/draft.go | 24 +-
internal/domain/article/draft_test.go | 16 +
internal/domain/brief/session.go | 2 +
internal/domain/brief/types.go | 24 +-
internal/domain/format/format.go | 333 ++++++++++++++++++
internal/domain/format/format_test.go | 111 ++++++
internal/domain/persona/persona.go | 101 ++++++
internal/domain/persona/persona_test.go | 27 ++
internal/domain/persona/seed.go | 44 +++
internal/handlers/workflow.go | 188 +++++++++-
internal/handlers/workflow_mode_test.go | 55 +++
static/css/style.css | 19 +-
static/index.html | 18 +-
static/js/script.js | 196 ++++++++++-
29 files changed, 1722 insertions(+), 74 deletions(-)
create mode 100644 docs/research/publishing-platforms.md
create mode 100644 internal/application/draft/format_guides.go
create mode 100644 internal/application/draft/format_guides/homepage_section.md
create mode 100644 internal/application/draft/format_guides/markdown_blog.md
create mode 100644 internal/application/draft/format_guides/note.md
create mode 100644 internal/application/draft/format_guides/qiita.md
create mode 100644 internal/application/draft/format_guides/zenn.md
create mode 100644 internal/domain/format/format.go
create mode 100644 internal/domain/format/format_test.go
create mode 100644 internal/domain/persona/persona.go
create mode 100644 internal/domain/persona/persona_test.go
create mode 100644 internal/domain/persona/seed.go
create mode 100644 internal/handlers/workflow_mode_test.go
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 10ea2f1..2b0a886 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -32,6 +32,9 @@ func main() {
// APIエンドポイントの設定
r.HandleFunc("/api/generate", handlers.GenerateArticleHandler).Methods("POST")
r.HandleFunc("/api/models", handlers.ListModelsHandler).Methods("GET")
+ r.HandleFunc("/api/personas", handlers.ListPersonasHandler).Methods("GET")
+ r.HandleFunc("/api/formats", handlers.ListFormatsHandler).Methods("GET")
+ r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/analyze", handlers.AnalyzeAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/{id}", handlers.GetAuthorStyleHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions", handlers.CreateBriefSessionHandler).Methods("POST")
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 316a8bf..7d3cbb9 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -4,7 +4,7 @@ Date: 2026-05-02
## Status
-Accepted. Extends and partially supersedes [ADR 0001](0001-three-phase-local-article-generation.md). The three-phase workflow (style analysis → interview → draft) is preserved. This ADR adds two orthogonal axes — **author persona** and **output format** — and reshapes UX, persistence, and prompt construction accordingly.
+Accepted. Implementation started in Phase B. Extends and partially supersedes [ADR 0001](0001-three-phase-local-article-generation.md). The three-phase workflow (style analysis → interview → draft) is preserved. This ADR adds two orthogonal axes — **author persona** and **output format** — and reshapes UX, persistence, and prompt construction accordingly.
## Context
@@ -60,16 +60,17 @@ An `OutputFormat` is a typed strategy with:
- a Markdown/HTML template surface (frontmatter, heading rules, code-fence policy, length envelope),
- a validator (e.g., Zenn requires frontmatter; note rejects code fences; homepage sections forbid `# title`),
- a default question-set extension (technical formats add "対象スタック", "実行環境", "想定読者の前提知識"; narrative formats add "導入エピソード", "結末で読者に届けたい感情"),
-- a system-prompt fragment merged into the draft prompt (instead of a single hard-coded fragment).
+- a system-prompt fragment merged into the draft prompt (instead of a single hard-coded fragment),
+- an embedded Markdown guide for notation details that are too verbose for the registry fragment.
Formats shipped in v2:
| Format | Validator highlights | Source/target |
|---|---|---|
-| `note_article` | Existing rules: starts with `# `, no code fences, `ですます調` default | note.com paste |
-| `markdown_blog` | Frontmatter optional, allows code fences, `## ` first heading allowed (Astro blogs at `cor-jp.com/blog`) | cor-jp.com |
-| `zenn_article` | Frontmatter required (`title`, `emoji`, `type`, `topics`, `published`), code fences allowed, technical tone bias | zenn.dev/cloudia |
-| `qiita_article` | Frontmatter required (`title`, `tags`), code blocks expected, technical tone bias | qiita.com/Cloudia_Cor_Inc |
+| `note_article` | Starts with `# `, no frontmatter, paste-safe note Markdown subset; plain code fences only when needed | note.com paste |
+| `markdown_blog` | `corsweb2024` Astro frontmatter required (`title`, `description`, `pubDate`, `author`, `category`, `tags`, `lang`, `featured`), Japanese-first, code fences require language | cor-jp.com |
+| `zenn_article` | Frontmatter required (`title`, `emoji`, `type`, `topics`, `published`), Zenn extensions (`:::message`, `:::details`, `@[card]`), `diff language` fences | zenn.dev/cloudia |
+| `qiita_article` | Frontmatter required (`title`, `tags`), Qiita extensions (`:::note`, HTML `details`), `diff_language` fences, `math` code blocks | qiita.com/Cloudia_Cor_Inc |
| `homepage_section` | HTML output, no `# title`, semantic sectioning, CTA placement guidance | static HTML embed |
The two axes compose: any persona can produce any format. The application layer rejects nonsensical combinations only when the persona explicitly excludes a format (configurable, not hard-coded).
@@ -125,6 +126,9 @@ New domain types under `internal/domain`:
- `OutputFormat` (id, validator, template fragment, default_question_extension)
- `FormatRegistry`
- `Validator` interface; per-format implementations (e.g., `NoteValidator`, `ZennValidator`)
+- `application/draft/format_guides`
+ - embedded Markdown guides for the final draft prompt (`note`, `markdown_blog`, `zenn`, `qiita`, `homepage_section`)
+ - guides encode editor notation differences that are too detailed for the short registry fragment
- `domain/article`
- `Draft` gains a `format_id`; `NewDraft` dispatches to the format's validator instead of hard-coded rules.
- `domain/brief`
@@ -137,7 +141,7 @@ New domain types under `internal/domain`:
- `AnalyzeAuthorStyleService` accepts a `persona_id` and persists the resulting guide as a new version under that persona; previous versions are preserved.
- `InterviewService` consults the active persona and format to assemble the question list before the first question.
-- `GenerateDraftService` resolves the prompt template fragment from the format's strategy and merges persona-specific tone hints.
+- `GenerateDraftService` resolves the prompt template fragment from the format's strategy, injects the active format's embedded Markdown guide, and merges persona-specific tone hints.
- New `RegenerateSectionService` accepts a draft id, a section selector (heading anchor or character range), the brief, and the persona+format; returns a candidate replacement for human review.
- New `StreamingDraftService` produces SSE chunks for the draft phase.
@@ -200,9 +204,9 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- A2 — [#18](https://github.com/terisuke/note_maker/issues/18) Stream LLM responses via SSE for follow-up and draft generation
- A3 — [#19](https://github.com/terisuke/note_maker/issues/19) Editable draft Markdown + per-section regenerate API
- A4 — [#20](https://github.com/terisuke/note_maker/issues/20) Surface deep-dive question rationale in prompt and UI
-- B1 — [#21](https://github.com/terisuke/note_maker/issues/21) Introduce Persona and OutputFormat domain concepts (registry + strategy)
+- B1 — [#21](https://github.com/terisuke/note_maker/issues/21) Introduce Persona and OutputFormat domain concepts (registry + strategy). Implemented by the first Phase B PR: `internal/domain/persona`, `internal/domain/format`, API selectors, prompt dispatch, and format-aware draft validation.
- B2 — [#22](https://github.com/terisuke/note_maker/issues/22) Generalize SourceFetcher beyond note.com (Zenn, Qiita, RSS, HTML)
-- B3 — [#23](https://github.com/terisuke/note_maker/issues/23) Format-specific prompt templates and draft validators (note / markdown_blog / zenn / qiita / homepage_section)
+- B3 — [#23](https://github.com/terisuke/note_maker/issues/23) Format-specific prompt templates and draft validators (note / markdown_blog / zenn / qiita / homepage_section). Partially implemented: validators and embedded format guides exist; scenario samples remain open.
- B4 — [#24](https://github.com/terisuke/note_maker/issues/24) Seed persona library with `terisuke` and `cloudia` profiles
- 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))
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 4b56a84..6954e75 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -131,23 +131,26 @@ Acceptance:
Files:
-- `internal/application/draft/templates/note_article.go`
-- `internal/application/draft/templates/markdown_blog.go`
-- `internal/application/draft/templates/zenn_article.go`
-- `internal/application/draft/templates/qiita_article.go`
-- `internal/application/draft/templates/homepage_section.go`
-
-Validators (in `internal/domain/format/validators/`):
-
-- `NoteValidator` — current rules: `# ` first line, no fences, `ですます調` recommendation.
-- `MarkdownBlogValidator` — frontmatter optional, `# ` or `## ` first heading, fences allowed.
-- `ZennValidator` — frontmatter required (`title`, `emoji`, `type ∈ {tech, idea}`, `topics: [...]`, `published: bool`), code fences allowed, no `# ` (Zenn auto-renders title from frontmatter).
-- `QiitaValidator` — frontmatter required (`title`, `tags: [...]`), code fences allowed.
+- `internal/domain/format/format.go`
+- `internal/application/draft/format_guides/note.md`
+- `internal/application/draft/format_guides/markdown_blog.md`
+- `internal/application/draft/format_guides/zenn.md`
+- `internal/application/draft/format_guides/qiita.md`
+- `internal/application/draft/format_guides/homepage_section.md`
+- `internal/application/draft/format_guides.go`
+
+Validators (in `internal/domain/format`):
+
+- `NoteValidator` — `# ` first line, no frontmatter, no Zenn/Qiita-specific extended Markdown; plain fences allowed only when needed.
+- `MarkdownBlogValidator` — `corsweb2024` Astro frontmatter required, `lang: ja`, category limited to `ai | engineering | founder | lab`, `# ` or `## ` first heading, code fences require language.
+- `ZennValidator` — frontmatter required (`title`, `emoji`, `type ∈ {tech, idea}`, `topics: [...]`, `published: bool`), rejects Qiita `:::note` and `diff_language`.
+- `QiitaValidator` — frontmatter required (`title`, `tags: [...]`), rejects Zenn `:::message`, `:::details`, `@[card]`, and `diff language`.
- `HomepageSectionValidator` — output is HTML, no `# `, requires at least one `` and one ` `, optional CTA `` block.
Acceptance:
- Each validator has a positive and negative unit test.
+- Each registered format has an embedded Markdown guide injected into the final draft prompt.
- Generating the same brief under different formats produces visibly different drafts: Zenn has frontmatter + many code fences; note has narrative paragraphs and ですます調; homepage_section is HTML with no `# `.
### B4 — Persona library seed
diff --git a/docs/research/publishing-platforms.md b/docs/research/publishing-platforms.md
new file mode 100644
index 0000000..b5763c4
--- /dev/null
+++ b/docs/research/publishing-platforms.md
@@ -0,0 +1,116 @@
+# Publishing platform research
+
+Date: 2026-05-02
+
+This note captures the practical acquisition and generation assumptions behind the first Persona / OutputFormat implementation.
+
+## Sources checked
+
+- Zenn official RSS article: `https://zenn.dev/zenn/articles/zenn-feed-rss`
+- Zenn official CLI guide: `https://zenn.dev/zenn/articles/zenn-cli-guide`
+- Zenn official Markdown guide: `https://zenn.dev/zenn/articles/markdown-guide`
+- Qiita API v2 docs: `https://qiita.com/api/v2/docs`
+- Qiita official Markdown cheat sheet: `https://qiita.com/Qiita/items/c686397e4a0f4f11683d`
+- note Markdown summary article: `https://note.com/akihamitsuki/n/nc7fdbff15bc8`
+- note help center RSS article: `https://www.help-note.com/hc/ja/articles/4402395202841-iframe-RSSで-自分のサイトにnoteを表示する`
+- note help center full RSS article: `https://www.help-note.com/hc/ja/articles/900001001246`
+- Cor.inc blog index: `https://cor-jp.com/blog/`
+- Cor.inc company blog repository: `https://github.com/Cor-Incorporated/corsweb2024`
+- `corsweb2024` files checked through GitHub API:
+ - `BLOG_FORMAT_GUIDE.md`
+ - `src/content/config.ts`
+ - `src/config/categories.ts`
+ - `src/content/blog/ja/ai-driven-development-workflow.md`
+ - `src/content/blog/ja/complete-multilingual-blog-expansion.md`
+ - `src/content/blog/ja/mcp-server-vibe-coding.md`
+ - `src/content/blog/ja/google-cloud-project-migration.md`
+- Public examples:
+ - `https://note.com/cor_instrument`
+ - `https://zenn.dev/cloudia`
+ - `https://qiita.com/Cloudia_Cor_Inc`
+ - `https://cor-jp.com/blog/`
+
+## Acquisition decisions
+
+| Platform | Stable read path | Notes |
+| --- | --- | --- |
+| note | `https://note.com/{user}/rss` plus existing compatibility fetcher | Official help documents RSS. Standard RSS includes excerpts; full RSS is note pro / custom-domain limited. Existing unofficial JSON endpoints stay compatibility-only. |
+| Zenn | `https://zenn.dev/{user}/feed?all=1` | Zenn documents user RSS and `all=1`. Unofficial JSON endpoints exist in the wild but are not the primary contract. |
+| Qiita | Qiita API v2 `GET /api/v2/users/:user_id/items` | Official API returns public item metadata and Markdown body. Unauthenticated clients are rate-limited to 60 requests/hour/IP. |
+| Cor.inc blog | `https://cor-jp.com/rss.xml`, `src/content/blog/ja/*.md` in `corsweb2024`, and semantic HTML fallback | The repo is the best source for exact frontmatter and Markdown body format. Article generation should target `src/content/blog/ja/{slug}.md` first; multilingual expansion can happen later through the existing site pipeline. |
+
+## Initial persona and format split
+
+| Persona | Default format | Practical voice hints |
+| --- | --- | --- |
+| `terisuke` | `note_article` | First person `僕` / `私`; note is for broad-reader technical experiments and reflections; Cor.inc blog is for company technical knowledge reports, implementation decisions, and employee-facing vision sharing. |
+| `cloudia` | `zenn_article` | First person `クラウディア` / `うち`; light Hakata-ben flavour; technical tutorial voice; exclamation-rich titles; code and step-by-step guidance. |
+
+| OutputFormat | Minimum validator |
+| --- | --- |
+| `note_article` | Starts with `# `, no YAML frontmatter, no platform-specific extended Markdown. Plain code fences are allowed only when needed. |
+| `markdown_blog` | Cor.inc Astro blog frontmatter is required: `title`, `description`, `pubDate`, `author`, `category`, `tags`, `lang`, `featured`. Body starts with `# ` or `## `. |
+| `zenn_article` | YAML frontmatter with `title`, `emoji`, `type`, `topics`, `published`; `type` is `tech` or `idea`. |
+| `qiita_article` | YAML frontmatter with `title` and `tags`; code fences allowed. |
+| `homepage_section` | HTML output containing ``, ``, and ` `. |
+
+## Editor notation split
+
+The output mode should also choose notation, not just prose.
+
+| Format | Practical notation policy |
+| --- | --- |
+| note | Keep to the subset that paste-converts reliably in the note editor: `##`, `###`, blockquotes, `-` lists, `1.` lists, `**bold**`, `~~strike~~`, horizontal rule, and plain code fences only when necessary. Avoid frontmatter, HTML, tables, footnotes, math, file-name code fences, and Zenn/Qiita blocks. |
+| Zenn | Use Zenn frontmatter and Zenn extensions: code fences may use `language:filename`; diff highlighting uses `diff language`; tips use `:::message`; warnings use `:::message alert`; collapsible sections use `:::details`; link cards use `@[card](URL)`. |
+| Qiita | Use Qiita frontmatter and Qiita extensions: code fences may use `language:filename`; diff highlighting uses `diff_language`; tips/warnings use `:::note info`, `:::note warn`, `:::note alert`; collapsible sections use HTML `...`; math should prefer `math` code fences or inline `$` backtick notation. |
+
+Generation and validation should reject obvious cross-platform leakage:
+
+- note must not output `:::message`, `:::note`, `:::details`, `@[card]`, HTML tables/details, or math blocks.
+- Zenn must not output Qiita `:::note` or `diff_language` fences.
+- Qiita must not output Zenn `:::message`, `:::details`, `@[card]`, or `diff language` fences.
+
+Implementation note: these notation policies are now captured as embedded Markdown guides under `internal/application/draft/format_guides/`. The final draft prompt injects only the selected format's guide, so the model sees concrete editor rules without receiving unrelated platform examples.
+
+## UX implication
+
+The app should not ask users to remember platform rules. The first usable version therefore exposes:
+
+- writer selector,
+- output target selector,
+- source/voice summary,
+- automatic default format per persona,
+- technical extra questions for Zenn / Qiita,
+- homepage CTA questions for HTML sections,
+- format-specific draft validation before showing the result.
+
+## Cor.inc blog generation target
+
+The company blog is an Astro content collection. The immediate practical output should be copy-pasteable Markdown for `corsweb2024/src/content/blog/ja/{slug}.md`, not an abstract blog draft.
+
+Required frontmatter shape:
+
+```yaml
+---
+title: "記事タイトル"
+description: "50-100文字程度の説明"
+pubDate: 2026-05-02
+author: "Terisuke"
+category: "engineering"
+tags: ["AI", "開発", "知見"]
+lang: "ja"
+featured: false
+isDraft: true
+---
+```
+
+Categories are defined in `src/config/categories.ts`: `ai`, `engineering`, `founder`, `lab`. The older guide text omits `engineering`, but the active Astro schema imports category ids from this config, so the app should follow the config.
+
+Practical style constraints:
+
+- Generate Japanese first: `lang: "ja"`.
+- Use direct company-blog prose: mainly `だ` / `である` / `する`.
+- Keep technical knowledge reports concrete: prerequisites, commands/code, verification result, learned constraints.
+- Keep vision-sharing articles useful for employees: why the decision matters, what behavior should change, and how it connects to company direction.
+- Include code fences only with a language marker.
+- Prefer `isDraft: true` for generated output so the pasted file is safe by default.
diff --git a/internal/application/brief/service.go b/internal/application/brief/service.go
index 80b0f69..93123ac 100644
--- a/internal/application/brief/service.go
+++ b/internal/application/brief/service.go
@@ -22,6 +22,8 @@ type InterviewService struct {
type StartSessionInput struct {
SessionID string
StyleProfileID string
+ PersonaID string
+ OutputFormatID string
Questions []domain.ArticleQuestion
}
@@ -43,7 +45,7 @@ func (s *InterviewService) StartSession(input StartSessionInput) (InterviewResul
if len(input.Questions) == 0 {
input.Questions = domain.FixedQuestions()
}
- session, err := domain.NewArticleBriefSessionWithQuestions(input.SessionID, input.StyleProfileID, input.Questions)
+ session, err := domain.NewArticleBriefSessionWithOptions(input.SessionID, input.StyleProfileID, input.PersonaID, input.OutputFormatID, "", input.Questions)
if err != nil {
return InterviewResult{}, err
}
diff --git a/internal/application/draft/format_guides.go b/internal/application/draft/format_guides.go
new file mode 100644
index 0000000..ef216eb
--- /dev/null
+++ b/internal/application/draft/format_guides.go
@@ -0,0 +1,32 @@
+package draft
+
+import (
+ "embed"
+ "io/fs"
+ "strings"
+)
+
+//go:embed format_guides/*.md
+var embeddedFormatGuides embed.FS
+
+const maxFormatGuideRunes = 5000
+
+var formatGuideFiles = map[string]string{
+ "note_article": "format_guides/note.md",
+ "markdown_blog": "format_guides/markdown_blog.md",
+ "zenn_article": "format_guides/zenn.md",
+ "qiita_article": "format_guides/qiita.md",
+ "homepage_section": "format_guides/homepage_section.md",
+}
+
+func formatGuideMarkdown(formatID string) string {
+ path, ok := formatGuideFiles[strings.TrimSpace(formatID)]
+ if !ok {
+ return ""
+ }
+ content, err := fs.ReadFile(embeddedFormatGuides, path)
+ if err != nil {
+ return ""
+ }
+ return truncateRunes(strings.TrimSpace(string(content)), maxFormatGuideRunes)
+}
diff --git a/internal/application/draft/format_guides/homepage_section.md b/internal/application/draft/format_guides/homepage_section.md
new file mode 100644
index 0000000..48f0df0
--- /dev/null
+++ b/internal/application/draft/format_guides/homepage_section.md
@@ -0,0 +1,26 @@
+# Homepage section guide
+
+Use this guide only for `homepage_section` output.
+
+## Output
+
+Return HTML only. Do not output Markdown.
+
+## Required elements
+
+- `
diff --git a/static/js/script.js b/static/js/script.js
index 447b29a..d7107fb 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -26,6 +26,7 @@ document.addEventListener('DOMContentLoaded', () => {
lastSubmittedAnswer: '',
answerAbortController: null,
draftAbortController: null,
+ pendingSectionReplacement: null,
};
const el = {
@@ -67,6 +68,13 @@ document.addEventListener('DOMContentLoaded', () => {
previewContent: document.getElementById('preview-content'),
markdownOutput: document.getElementById('markdown-output'),
copy: document.getElementById('copy-btn'),
+ copyPreview: document.getElementById('copy-preview-btn'),
+ regenerateSection: document.getElementById('regenerate-section-btn'),
+ sectionStatus: document.getElementById('section-status'),
+ sectionCandidate: document.getElementById('section-candidate'),
+ sectionCandidateOutput: document.getElementById('section-candidate-output'),
+ acceptSection: document.getElementById('accept-section-btn'),
+ rejectSection: document.getElementById('reject-section-btn'),
loading: document.getElementById('loading'),
loadingText: document.getElementById('loading-text'),
errorArea: document.getElementById('error-message-area'),
@@ -94,6 +102,14 @@ document.addEventListener('DOMContentLoaded', () => {
el.generateDraft.addEventListener('click', generateDraft);
el.cancelDraft.addEventListener('click', () => state.draftAbortController?.abort());
el.copy.addEventListener('click', copyMarkdown);
+ el.copyPreview.addEventListener('click', copyPreviewText);
+ el.markdownOutput.addEventListener('input', syncDraftEditor);
+ el.markdownOutput.addEventListener('keyup', updateSectionControls);
+ el.markdownOutput.addEventListener('click', updateSectionControls);
+ el.markdownOutput.addEventListener('select', updateSectionControls);
+ el.regenerateSection.addEventListener('click', regenerateCurrentSection);
+ el.acceptSection.addEventListener('click', acceptSectionCandidate);
+ el.rejectSection.addEventListener('click', rejectSectionCandidate);
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', () => setActiveTab(button.dataset.tab));
@@ -329,6 +345,8 @@ document.addEventListener('DOMContentLoaded', () => {
el.previewContent.innerHTML = '';
el.evaluationSummary.textContent = '';
el.verificationSummary.textContent = '';
+ el.sectionStatus.textContent = '';
+ rejectSectionCandidate();
el.draftResult.classList.remove('hidden');
setActiveTab('markdown');
try {
@@ -355,7 +373,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (event === 'chunk') {
draftBuffer += data.text || '';
el.markdownOutput.value = draftBuffer;
- el.previewContent.innerHTML = marked.parse(draftBuffer);
+ syncDraftEditor();
return;
}
if (event === 'result') {
@@ -879,11 +897,170 @@ document.addEventListener('DOMContentLoaded', () => {
`;
renderVerification(data.verification);
el.markdownOutput.value = data.draft;
- el.previewContent.innerHTML = marked.parse(data.draft);
+ syncDraftEditor();
el.draftResult.classList.remove('hidden');
setActiveTab('preview');
}
+ function syncDraftEditor() {
+ el.previewContent.innerHTML = marked.parse(el.markdownOutput.value || '');
+ updateSectionControls();
+ }
+
+ function updateSectionControls() {
+ const section = currentMarkdownSection();
+ el.regenerateSection.disabled = !section || !state.profileId || !state.sessionId;
+ if (!el.markdownOutput.value.trim()) {
+ el.sectionStatus.textContent = '';
+ return;
+ }
+ el.sectionStatus.textContent = section
+ ? `選択中のセクション: ## ${section.heading}`
+ : '再生成するには Markdown タブで ## 見出し配下にカーソルを置いてください。';
+ }
+
+ async function regenerateCurrentSection() {
+ clearError();
+ const section = currentMarkdownSection();
+ if (!section) {
+ showError('再生成する ## セクションにカーソルを置いてください');
+ return;
+ }
+ el.regenerateSection.disabled = true;
+ el.sectionStatus.textContent = `## ${section.heading} を再生成しています。`;
+ rejectSectionCandidate();
+ try {
+ const data = await requestJSON(`/api/drafts/${encodeURIComponent(state.sessionId)}/regenerate-section`, {
+ method: 'POST',
+ body: {
+ style_profile_id: state.profileId,
+ session_id: state.sessionId,
+ persona_id: currentPersonaId(),
+ output_format_id: currentFormatId(),
+ draft_model: el.draftModel.value,
+ draft_markdown: el.markdownOutput.value,
+ section_anchor: section.anchor,
+ },
+ });
+ state.pendingSectionReplacement = data;
+ el.sectionCandidateOutput.value = data.replacement_markdown || '';
+ el.sectionCandidate.classList.remove('hidden');
+ el.sectionStatus.textContent = `## ${section.heading} の再生成候補を確認してください。`;
+ setActiveTab('markdown');
+ el.sectionCandidateOutput.focus();
+ } catch (error) {
+ showError(`セクション再生成に失敗しました: ${error.message}`);
+ updateSectionControls();
+ } finally {
+ el.regenerateSection.disabled = !currentMarkdownSection();
+ }
+ }
+
+ function acceptSectionCandidate() {
+ if (!state.pendingSectionReplacement) {
+ return;
+ }
+ const responseSection = state.pendingSectionReplacement.section || {};
+ const section = markdownSections(el.markdownOutput.value).find((candidate) => candidate.anchor === responseSection.anchor)
+ || currentMarkdownSection();
+ const updated = replaceMarkdownSection(el.markdownOutput.value, section, el.sectionCandidateOutput.value);
+ if (!updated) {
+ showError('候補の反映に失敗しました。対象セクションを再選択して再生成してください。');
+ return;
+ }
+ el.markdownOutput.value = updated;
+ rejectSectionCandidate();
+ syncDraftEditor();
+ el.markdownOutput.focus();
+ }
+
+ function rejectSectionCandidate() {
+ state.pendingSectionReplacement = null;
+ el.sectionCandidate.classList.add('hidden');
+ el.sectionCandidateOutput.value = '';
+ }
+
+ function currentMarkdownSection() {
+ return sectionAtOffset(el.markdownOutput.value, el.markdownOutput.selectionStart || 0);
+ }
+
+ function sectionAtOffset(markdown, offset) {
+ const sections = markdownSections(markdown);
+ return sections.find((section) => offset >= section.start && offset < section.end)
+ || sections.find((section) => offset === section.end)
+ || null;
+ }
+
+ function markdownSections(markdown) {
+ const headings = [];
+ let offset = 0;
+ const lines = markdown.match(/[^\n]*(?:\n|$)/g) || [];
+ lines.forEach((line) => {
+ if (!line) {
+ return;
+ }
+ const trimmed = line.replace(/\n$/, '').trim();
+ if (trimmed.startsWith('## ') && !trimmed.startsWith('### ')) {
+ const heading = trimmed.slice(3).trim();
+ headings.push({ offset, heading });
+ }
+ offset += line.length;
+ });
+ return headings.map((heading, index) => {
+ const end = index + 1 < headings.length ? headings[index + 1].offset : markdown.length;
+ return {
+ anchor: sectionAnchor(heading.heading),
+ heading: heading.heading,
+ start: heading.offset,
+ end,
+ content: markdown.slice(heading.offset, end),
+ };
+ });
+ }
+
+ function replaceMarkdownSection(markdown, section, replacement) {
+ if (!section || section.start < 0 || section.end < section.start || section.end > markdown.length) {
+ return '';
+ }
+ const candidate = normalizeReplacementSection(replacement, section.heading);
+ if (!candidate) {
+ return '';
+ }
+ return markdown.slice(0, section.start) + candidate + markdown.slice(section.end);
+ }
+
+ function normalizeReplacementSection(replacement, heading) {
+ let candidate = String(replacement || '').trim();
+ if (!candidate) {
+ return '';
+ }
+ if (!candidate.startsWith('## ')) {
+ candidate = `## ${heading}\n\n${candidate}`;
+ }
+ if (!candidate.endsWith('\n')) {
+ candidate += '\n';
+ }
+ const sections = markdownSections(candidate);
+ if (sections.length !== 1 || sections[0].anchor !== sectionAnchor(heading)) {
+ return '';
+ }
+ return candidate;
+ }
+
+ function sectionAnchor(heading) {
+ return String(heading || '')
+ .trim()
+ .replace(/^##\s+/, '')
+ .toLowerCase()
+ .replaceAll(' ', '-')
+ .replaceAll(' ', '-')
+ .replaceAll('_', '-')
+ .replaceAll('/', '-')
+ .replace(/[:"'`?!:?!]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ }
+
function renderVerification(verification) {
if (!verification || !(verification.performed ?? verification.Performed)) {
el.verificationSummary.className = 'evaluation';
@@ -1024,6 +1201,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
+ function copyPreviewText() {
+ navigator.clipboard.writeText(el.previewContent.innerText || '').then(() => {
+ const original = el.copyPreview.textContent;
+ el.copyPreview.textContent = 'コピーしました';
+ setTimeout(() => {
+ el.copyPreview.textContent = original;
+ }, 1600);
+ });
+ }
+
function setLoading(active, text = '') {
el.loading.classList.toggle('hidden', !active);
if (text) {
From 3f461302fb092a409e0630f722c20d7445e088bb Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sat, 2 May 2026 23:31:06 +0900
Subject: [PATCH 12/33] Generalize source fetchers for #22
---
cmd/scenario/source_fetch/main.go | 71 ++++++
.../issue-22-source-fetchers-2026-05-02.md | 68 ++++++
internal/domain/source/source.go | 86 +++++++
internal/handlers/generate.go | 4 +-
internal/handlers/workflow.go | 4 +-
internal/infrastructure/source/html.go | 111 +++++++++
internal/infrastructure/source/qiita.go | 146 ++++++++++++
internal/infrastructure/source/router.go | 210 ++++++++++++++++
internal/infrastructure/source/rss.go | 224 ++++++++++++++++++
internal/infrastructure/source/rss_test.go | 130 ++++++++++
internal/infrastructure/source/text.go | 61 +++++
internal/infrastructure/source/zenn.go | 63 +++++
12 files changed, 1174 insertions(+), 4 deletions(-)
create mode 100644 cmd/scenario/source_fetch/main.go
create mode 100644 docs/validation/issue-22-source-fetchers-2026-05-02.md
create mode 100644 internal/domain/source/source.go
create mode 100644 internal/infrastructure/source/html.go
create mode 100644 internal/infrastructure/source/qiita.go
create mode 100644 internal/infrastructure/source/router.go
create mode 100644 internal/infrastructure/source/rss.go
create mode 100644 internal/infrastructure/source/rss_test.go
create mode 100644 internal/infrastructure/source/text.go
create mode 100644 internal/infrastructure/source/zenn.go
diff --git a/cmd/scenario/source_fetch/main.go b/cmd/scenario/source_fetch/main.go
new file mode 100644
index 0000000..48f9183
--- /dev/null
+++ b/cmd/scenario/source_fetch/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ sourcefetch "github.com/teradakousuke/note_maker/internal/infrastructure/source"
+)
+
+const defaultOutputDir = "tmp/source_fetch"
+
+func main() {
+ if os.Getenv("RUN_SOURCE_FETCH_SCENARIO") != "1" {
+ fatalf("set RUN_SOURCE_FETCH_SCENARIO=1 to run public source fetch scenario")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+
+ outputDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)
+ if err := os.MkdirAll(outputDir, 0o755); err != nil {
+ fatalf("create output dir: %v", err)
+ }
+
+ limit := 1
+ fetcher := sourcefetch.NewAuthorStyleFetcherWithClient(&http.Client{Timeout: 20 * time.Second})
+ selectors := map[string]string{
+ "note": envOrDefault("SOURCE_FETCH_NOTE", "note:cor_instrument"),
+ "zenn": envOrDefault("SOURCE_FETCH_ZENN", "zenn:zenn"),
+ "qiita": envOrDefault("SOURCE_FETCH_QIITA", "qiita:Qiita"),
+ "rss": envOrDefault("SOURCE_FETCH_RSS", "rss:https://zenn.dev/zenn/feed"),
+ }
+
+ for name, selector := range selectors {
+ started := time.Now()
+ articles, err := fetcher.FetchUserLatestArticles(ctx, selector, limit)
+ if err != nil {
+ fatalf("fetch %s (%s): %v", name, selector, err)
+ }
+ path := filepath.Join(outputDir, name+".json")
+ writeJSON(path, articles)
+ fmt.Printf("%s selector=%s articles=%d elapsed_seconds=%.2f output=%s\n", name, selector, len(articles), time.Since(started).Seconds(), path)
+ }
+}
+
+func writeJSON(path string, value any) {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatalf("encode %s: %v", path, err)
+ }
+ if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil {
+ fatalf("write %s: %v", path, err)
+ }
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/docs/validation/issue-22-source-fetchers-2026-05-02.md b/docs/validation/issue-22-source-fetchers-2026-05-02.md
new file mode 100644
index 0000000..c3716d6
--- /dev/null
+++ b/docs/validation/issue-22-source-fetchers-2026-05-02.md
@@ -0,0 +1,68 @@
+# Issue 22 source fetcher validation
+
+Date: 2026-05-02
+
+## Scope
+
+Validate [#22](https://github.com/terisuke/note_maker/issues/22): route source acquisition beyond note.com to Zenn, Qiita, RSS/Atom, and generic HTML while keeping normal tests offline.
+
+## Current source facts checked
+
+- Qiita API v2 documents `GET /api/v2/users/:user_id/items` as the public user item list endpoint in newest order, with `page` and `per_page` parameters.
+- Qiita API v2 documents unauthenticated access as limited to `60` requests per hour per IP address, so the fetcher uses one request per user-list scenario and no polling loop.
+- Zenn's official RSS article documents user feeds as `https://zenn.dev/ユーザー名/feed`.
+- Zenn's official RSS article documents `?all=1` for including all public posts instead of the default limited feed, so the Zenn fetcher uses that query for user lists.
+- RSS/Atom parsing is implemented through standard XML feeds, with RSS 2.0 item fields and Atom entries handled in one `RSSFetcher`.
+
+## Implementation
+
+- `internal/domain/source` defines `Kind`, `Ref`, `ProfileSnapshot`, and `ArticleSnapshot`.
+- `internal/infrastructure/source.Router` dispatches by explicit selector or URL host:
+ - `note:` and note.com URLs go to the existing note fetcher.
+ - `zenn:` and zenn.dev URLs go to Zenn RSS/HTML fetchers.
+ - `qiita:` and qiita.com URLs go to Qiita API v2 fetchers.
+ - `rss:` and feed-like URLs go to RSS/Atom parsing.
+ - `html:` and unknown article URLs go to semantic HTML extraction.
+- `AnalyzeAuthorStyleHandler` and the legacy article-generation handler now use the router through an adapter that still satisfies the existing `FetchArticle` / `FetchUserLatestArticles` application interface.
+
+## Scenario
+
+Command:
+
+```bash
+RUN_SOURCE_FETCH_SCENARIO=1 \
+SCENARIO_OUTPUT_DIR=tmp/source_fetch_issue22 \
+SOURCE_FETCH_NOTE=note:cor_instrument \
+SOURCE_FETCH_ZENN=zenn:zenn \
+SOURCE_FETCH_QIITA=qiita:Qiita \
+SOURCE_FETCH_RSS=rss:https://zenn.dev/zenn/feed \
+go run ./cmd/scenario/source_fetch
+```
+
+Result:
+
+| Source | Selector | Articles | Elapsed | Output |
+|---|---|---:|---:|---|
+| Qiita | `qiita:Qiita` | 1 | 0.68s | `tmp/source_fetch_issue22/qiita.json` |
+| RSS | `rss:https://zenn.dev/zenn/feed` | 1 | 0.18s | `tmp/source_fetch_issue22/rss.json` |
+| note | `note:cor_instrument` | 1 | 0.66s | `tmp/source_fetch_issue22/note.json` |
+| Zenn | `zenn:zenn` | 1 | 0.19s | `tmp/source_fetch_issue22/zenn.json` |
+
+Recent-source check:
+
+- Qiita returned `Qiita アップデートサマリー - 2026年4月`, which is inside the requested recent two-month window from 2026-05-02.
+- Zenn/RSS returned Zenn official content from the current public feed path.
+- note returned the latest public `cor_instrument` article available through the existing note source path.
+
+## Tests
+
+```bash
+go test ./...
+```
+
+Focused source tests cover:
+
+- RSS 2.0 `content:encoded` parsing.
+- Atom entry parsing.
+- explicit `zenn:`, `qiita:`, and `rss:` selector routing.
+- host-based routing for note.com, zenn.dev, qiita.com, RSS-like URLs, and generic HTML.
diff --git a/internal/domain/source/source.go b/internal/domain/source/source.go
new file mode 100644
index 0000000..6989836
--- /dev/null
+++ b/internal/domain/source/source.go
@@ -0,0 +1,86 @@
+package source
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/teradakousuke/note_maker/internal/domain/article"
+)
+
+// Kind identifies a public writing source.
+type Kind string
+
+const (
+ KindNote Kind = "note"
+ KindZenn Kind = "zenn"
+ KindQiita Kind = "qiita"
+ KindRSS Kind = "rss"
+ KindHTML Kind = "html"
+)
+
+// Ref points to a public author, feed, or article.
+type Ref struct {
+ Kind Kind `json:"kind"`
+ Ref string `json:"ref,omitempty"`
+ URL string `json:"url,omitempty"`
+}
+
+// Validate rejects empty or unknown references.
+func (r Ref) Validate() error {
+ switch r.Kind {
+ case KindNote, KindZenn, KindQiita:
+ if strings.TrimSpace(r.Ref) == "" && strings.TrimSpace(r.URL) == "" {
+ return fmt.Errorf("%s source requires ref or url", r.Kind)
+ }
+ case KindRSS, KindHTML:
+ if strings.TrimSpace(r.URL) == "" {
+ return fmt.Errorf("%s source requires url", r.Kind)
+ }
+ default:
+ return fmt.Errorf("unsupported source kind %q", r.Kind)
+ }
+ return nil
+}
+
+// ProfileSnapshot describes a source account/feed at fetch time.
+type ProfileSnapshot struct {
+ Kind Kind `json:"kind"`
+ Ref string `json:"ref,omitempty"`
+ URL string `json:"url,omitempty"`
+ Title string `json:"title,omitempty"`
+ FetchedAt time.Time `json:"fetched_at"`
+}
+
+// ArticleSnapshot is normalized source material from any public source.
+type ArticleSnapshot struct {
+ ID string `json:"id,omitempty"`
+ Kind Kind `json:"kind"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ PublishedAt time.Time `json:"published_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+ FetchedAt time.Time `json:"fetched_at"`
+}
+
+// ToArticle converts normalized source material into the existing article domain.
+func (a ArticleSnapshot) ToArticle() article.Article {
+ return article.Article{
+ URL: strings.TrimSpace(a.URL),
+ Title: strings.TrimSpace(a.Title),
+ Content: strings.TrimSpace(a.Content),
+ }
+}
+
+// Articles converts snapshots into article domain values.
+func Articles(snapshots []ArticleSnapshot) []article.Article {
+ articles := make([]article.Article, 0, len(snapshots))
+ for _, snapshot := range snapshots {
+ if strings.TrimSpace(snapshot.Content) == "" {
+ continue
+ }
+ articles = append(articles, snapshot.ToArticle())
+ }
+ return articles
+}
diff --git a/internal/handlers/generate.go b/internal/handlers/generate.go
index d95c187..97f4580 100644
--- a/internal/handlers/generate.go
+++ b/internal/handlers/generate.go
@@ -9,7 +9,7 @@ import (
articleapp "github.com/teradakousuke/note_maker/internal/application/article"
articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
"github.com/teradakousuke/note_maker/internal/infrastructure/llamacpp"
- notenote "github.com/teradakousuke/note_maker/internal/infrastructure/note"
+ sourcefetch "github.com/teradakousuke/note_maker/internal/infrastructure/source"
)
// GenerateRequest は記事生成のリクエストボディの構造体
@@ -67,7 +67,7 @@ func newDefaultArticleService() (articleService, error) {
if err != nil {
return nil, err
}
- return articleapp.NewService(notenote.NewFetcher(), generator, nil), nil
+ return articleapp.NewService(sourcefetch.NewAuthorStyleFetcher(), generator, nil), nil
}
func handleGenerateArticle(service articleService, w http.ResponseWriter, r *http.Request) {
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index de065df..f89ba0e 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -22,8 +22,8 @@ import (
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/llamacpp"
- notenote "github.com/teradakousuke/note_maker/internal/infrastructure/note"
"github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+ sourcefetch "github.com/teradakousuke/note_maker/internal/infrastructure/source"
)
var workflowStore = newWorkflowStore()
@@ -177,7 +177,7 @@ func AnalyzeAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
return
}
- service := authorstyleapp.NewAnalyzeAuthorStyleService(notenote.NewFetcher(), nil)
+ service := authorstyleapp.NewAnalyzeAuthorStyleService(sourcefetch.NewAuthorStyleFetcher(), nil)
result, err := service.Analyze(r.Context(), authorstyleapp.AnalyzeRequest{
Username: req.Username,
ArticleURLs: req.ArticleURLs,
diff --git a/internal/infrastructure/source/html.go b/internal/infrastructure/source/html.go
new file mode 100644
index 0000000..d244fae
--- /dev/null
+++ b/internal/infrastructure/source/html.go
@@ -0,0 +1,111 @@
+package source
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/PuerkitoBio/goquery"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+// HTMLFetcher extracts readable content from public HTML pages.
+type HTMLFetcher struct {
+ client *http.Client
+}
+
+// NewHTMLFetcher creates a semantic HTML fetcher.
+func NewHTMLFetcher(client *http.Client) *HTMLFetcher {
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ return &HTMLFetcher{client: client}
+}
+
+// FetchArticle retrieves one public HTML article.
+func (f *HTMLFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ if err := ref.Validate(); err != nil {
+ return nil, err
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(ref.URL), nil)
+ if err != nil {
+ return nil, fmt.Errorf("create html request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ response, err := f.client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("fetch html: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("fetch html: unexpected status %s", response.Status)
+ }
+ doc, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("parse html: %w", err)
+ }
+ title := firstNonEmpty(
+ selectionAttr(doc, `meta[property="og:title"]`, "content"),
+ selectionAttr(doc, `meta[name="twitter:title"]`, "content"),
+ selectionText(doc, "h1"),
+ selectionText(doc, "title"),
+ )
+ content := firstNonEmpty(
+ selectionBlockText(doc, "article"),
+ selectionBlockText(doc, "main"),
+ selectionBlockText(doc, `[role="main"]`),
+ selectionBlockText(doc, ".content"),
+ )
+ if content == "" {
+ return nil, fmt.Errorf("html did not contain extractable article content")
+ }
+ now := time.Now().UTC()
+ return &sourcedomain.ArticleSnapshot{
+ ID: ref.URL,
+ Kind: sourcedomain.KindHTML,
+ URL: ref.URL,
+ Title: normalizeWhitespace(title),
+ Content: content,
+ FetchedAt: now,
+ }, nil
+}
+
+// FetchList is intentionally unsupported for arbitrary HTML because page-list
+// extraction is site-specific and would silently scrape the wrong surface.
+func (f *HTMLFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ article, err := f.FetchArticle(ctx, ref)
+ if err != nil {
+ return nil, err
+ }
+ return []sourcedomain.ArticleSnapshot{*article}, nil
+}
+
+func selectionText(doc *goquery.Document, selector string) string {
+ return strings.TrimSpace(doc.Find(selector).First().Text())
+}
+
+func selectionBlockText(doc *goquery.Document, selector string) string {
+ selection := doc.Find(selector).First()
+ if selection.Length() == 0 {
+ return ""
+ }
+ parts := make([]string, 0)
+ selection.Find("h1,h2,h3,h4,p,li,blockquote,pre,code").Each(func(_ int, s *goquery.Selection) {
+ text := normalizeWhitespace(s.Text())
+ if text != "" {
+ parts = append(parts, text)
+ }
+ })
+ if len(parts) == 0 {
+ return normalizeParagraphs(selection.Text())
+ }
+ return normalizeParagraphs(strings.Join(parts, "\n\n"))
+}
+
+func selectionAttr(doc *goquery.Document, selector, attr string) string {
+ value, _ := doc.Find(selector).First().Attr(attr)
+ return strings.TrimSpace(value)
+}
diff --git a/internal/infrastructure/source/qiita.go b/internal/infrastructure/source/qiita.go
new file mode 100644
index 0000000..84bfd88
--- /dev/null
+++ b/internal/infrastructure/source/qiita.go
@@ -0,0 +1,146 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+// QiitaFetcher reads public Qiita posts through Qiita API v2.
+type QiitaFetcher struct {
+ client *http.Client
+}
+
+// NewQiitaFetcher creates a Qiita API fetcher.
+func NewQiitaFetcher(client *http.Client) *QiitaFetcher {
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ return &QiitaFetcher{client: client}
+}
+
+// FetchList fetches public posts from a user in newest order.
+func (f *QiitaFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ userID := strings.Trim(strings.TrimSpace(ref.Ref), "/")
+ if userID == "" && strings.TrimSpace(ref.URL) != "" {
+ parsed, err := url.Parse(strings.TrimSpace(ref.URL))
+ if err != nil {
+ return nil, fmt.Errorf("parse qiita url: %w", err)
+ }
+ parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ if len(parts) > 0 {
+ userID = parts[0]
+ }
+ }
+ if userID == "" {
+ return nil, fmt.Errorf("qiita user ref is required")
+ }
+ if limit <= 0 {
+ limit = 5
+ }
+ apiURL := fmt.Sprintf("https://qiita.com/api/v2/users/%s/items?page=1&per_page=%d", url.PathEscape(userID), min(limit, 100))
+ var payload []qiitaItem
+ if err := f.getJSON(ctx, apiURL, &payload); err != nil {
+ return nil, err
+ }
+ return qiitaSnapshots(payload, limit), nil
+}
+
+// FetchArticle fetches one public Qiita item by item id parsed from its URL.
+func (f *QiitaFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ itemID := qiitaItemID(ref.URL)
+ if itemID == "" {
+ return nil, fmt.Errorf("could not extract qiita item id from %s", ref.URL)
+ }
+ apiURL := fmt.Sprintf("https://qiita.com/api/v2/items/%s", url.PathEscape(itemID))
+ var payload qiitaItem
+ if err := f.getJSON(ctx, apiURL, &payload); err != nil {
+ return nil, err
+ }
+ snapshots := qiitaSnapshots([]qiitaItem{payload}, 1)
+ if len(snapshots) == 0 {
+ return nil, fmt.Errorf("qiita item had no body")
+ }
+ return &snapshots[0], nil
+}
+
+func (f *QiitaFetcher) getJSON(ctx context.Context, apiURL string, out any) error {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return fmt.Errorf("create qiita request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "application/json")
+ response, err := f.client.Do(request)
+ if err != nil {
+ return fmt.Errorf("fetch qiita api: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return fmt.Errorf("fetch qiita api: unexpected status %s", response.Status)
+ }
+ if err := json.NewDecoder(response.Body).Decode(out); err != nil {
+ return fmt.Errorf("decode qiita api: %w", err)
+ }
+ return nil
+}
+
+func qiitaSnapshots(items []qiitaItem, limit int) []sourcedomain.ArticleSnapshot {
+ now := time.Now().UTC()
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, min(limit, len(items)))
+ for _, item := range items {
+ if len(snapshots) >= limit {
+ break
+ }
+ content := strings.TrimSpace(item.Body)
+ if content == "" {
+ content = htmlToParagraphText(item.RenderedBody)
+ }
+ if content == "" {
+ continue
+ }
+ createdAt := parseFeedTime(item.CreatedAt)
+ updatedAt := parseFeedTime(item.UpdatedAt)
+ snapshots = append(snapshots, sourcedomain.ArticleSnapshot{
+ ID: item.ID,
+ Kind: sourcedomain.KindQiita,
+ URL: item.URL,
+ Title: item.Title,
+ Content: content,
+ PublishedAt: createdAt,
+ UpdatedAt: updatedAt,
+ FetchedAt: now,
+ })
+ }
+ return snapshots
+}
+
+func qiitaItemID(rawURL string) string {
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil {
+ return ""
+ }
+ parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ for i, part := range parts {
+ if part == "items" && i+1 < len(parts) {
+ return parts[i+1]
+ }
+ }
+ return ""
+}
+
+type qiitaItem struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ RenderedBody string `json:"rendered_body"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
diff --git a/internal/infrastructure/source/router.go b/internal/infrastructure/source/router.go
new file mode 100644
index 0000000..06886f2
--- /dev/null
+++ b/internal/infrastructure/source/router.go
@@ -0,0 +1,210 @@
+package source
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+ notenote "github.com/teradakousuke/note_maker/internal/infrastructure/note"
+)
+
+// Router dispatches public source refs to concrete fetchers.
+type Router struct {
+ note *notenote.Fetcher
+ zenn *ZennFetcher
+ qiita *QiitaFetcher
+ rss *RSSFetcher
+ html *HTMLFetcher
+}
+
+// NewRouter creates a source router with shared HTTP settings.
+func NewRouter() *Router {
+ return NewRouterWithClient(nil)
+}
+
+// NewRouterWithClient creates a source router with a caller-provided client.
+func NewRouterWithClient(client *http.Client) *Router {
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ return &Router{
+ note: notenote.NewFetcherWithClient(client),
+ zenn: NewZennFetcher(client),
+ qiita: NewQiitaFetcher(client),
+ rss: NewRSSFetcher(client),
+ html: NewHTMLFetcher(client),
+ }
+}
+
+// FetchList fetches recent articles for an author/feed ref.
+func (r *Router) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ ref = normalizeRef(ref)
+ switch ref.Kind {
+ case sourcedomain.KindNote:
+ articles, err := r.note.FetchUserLatestArticles(ctx, ref.Ref, limit)
+ return snapshotsFromArticles(sourcedomain.KindNote, articles), err
+ case sourcedomain.KindZenn:
+ return r.zenn.FetchList(ctx, ref, limit)
+ case sourcedomain.KindQiita:
+ return r.qiita.FetchList(ctx, ref, limit)
+ case sourcedomain.KindRSS:
+ return r.rss.FetchList(ctx, ref, limit)
+ case sourcedomain.KindHTML:
+ return r.html.FetchList(ctx, ref, limit)
+ default:
+ return nil, fmt.Errorf("unsupported source kind %q", ref.Kind)
+ }
+}
+
+// FetchArticle fetches one article by URL.
+func (r *Router) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ ref = normalizeRef(ref)
+ switch ref.Kind {
+ case sourcedomain.KindNote:
+ article, err := r.note.FetchArticle(ctx, ref.URL)
+ if err != nil {
+ return nil, err
+ }
+ snapshot := snapshotFromArticle(sourcedomain.KindNote, *article)
+ return &snapshot, nil
+ case sourcedomain.KindZenn:
+ return r.zenn.FetchArticle(ctx, ref)
+ case sourcedomain.KindQiita:
+ return r.qiita.FetchArticle(ctx, ref)
+ case sourcedomain.KindRSS:
+ return r.rss.FetchArticle(ctx, ref)
+ case sourcedomain.KindHTML:
+ return r.html.FetchArticle(ctx, ref)
+ default:
+ return nil, fmt.Errorf("unsupported source kind %q", ref.Kind)
+ }
+}
+
+// AuthorStyleFetcher adapts Router to application/authorstyle.SourceFetcher.
+type AuthorStyleFetcher struct {
+ router *Router
+}
+
+// NewAuthorStyleFetcher creates an author-style source adapter.
+func NewAuthorStyleFetcher() *AuthorStyleFetcher {
+ return &AuthorStyleFetcher{router: NewRouter()}
+}
+
+// NewAuthorStyleFetcherWithClient creates a testable author-style source adapter.
+func NewAuthorStyleFetcherWithClient(client *http.Client) *AuthorStyleFetcher {
+ return &AuthorStyleFetcher{router: NewRouterWithClient(client)}
+}
+
+// FetchArticle routes by URL host. note.com remains enforced inside the note fetcher only.
+func (f *AuthorStyleFetcher) FetchArticle(ctx context.Context, articleURL string) (*articledomain.Article, error) {
+ snapshot, err := f.router.FetchArticle(ctx, RefFromURL(articleURL))
+ if err != nil {
+ return nil, err
+ }
+ article := snapshot.ToArticle()
+ return &article, nil
+}
+
+// FetchUserLatestArticles supports explicit refs such as zenn:cloudia,
+// qiita:Cloudia_Cor_Inc, rss:https://example.com/feed.xml, and legacy note usernames.
+func (f *AuthorStyleFetcher) FetchUserLatestArticles(ctx context.Context, username string, limit int) ([]articledomain.Article, error) {
+ snapshots, err := f.router.FetchList(ctx, RefFromSelector(username), limit)
+ if err != nil {
+ return nil, err
+ }
+ return sourcedomain.Articles(snapshots), nil
+}
+
+// RefFromSelector parses UI/API source selectors.
+func RefFromSelector(selector string) sourcedomain.Ref {
+ selector = strings.TrimSpace(selector)
+ if parsed, ok := parseKindSelector(selector); ok {
+ return parsed
+ }
+ if looksLikeURL(selector) {
+ return RefFromURL(selector)
+ }
+ return sourcedomain.Ref{Kind: sourcedomain.KindNote, Ref: strings.Trim(selector, "/")}
+}
+
+// RefFromURL routes article/feed URLs by host.
+func RefFromURL(rawURL string) sourcedomain.Ref {
+ rawURL = strings.TrimSpace(rawURL)
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: rawURL}
+ }
+ host := strings.ToLower(parsed.Hostname())
+ switch {
+ case host == "note.com" || strings.HasSuffix(host, ".note.com"):
+ return sourcedomain.Ref{Kind: sourcedomain.KindNote, URL: rawURL}
+ case host == "zenn.dev" || strings.HasSuffix(host, ".zenn.dev"):
+ return sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: rawURL}
+ case host == "qiita.com" || strings.HasSuffix(host, ".qiita.com"):
+ return sourcedomain.Ref{Kind: sourcedomain.KindQiita, URL: rawURL}
+ case strings.Contains(strings.ToLower(parsed.Path), "rss") || strings.Contains(strings.ToLower(parsed.Path), "feed") || strings.HasSuffix(strings.ToLower(parsed.Path), ".xml"):
+ return sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: rawURL}
+ default:
+ return sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: rawURL}
+ }
+}
+
+func normalizeRef(ref sourcedomain.Ref) sourcedomain.Ref {
+ if ref.Kind == "" {
+ if strings.TrimSpace(ref.URL) != "" {
+ return RefFromURL(ref.URL)
+ }
+ return RefFromSelector(ref.Ref)
+ }
+ return ref
+}
+
+func parseKindSelector(selector string) (sourcedomain.Ref, bool) {
+ prefix, value, ok := strings.Cut(selector, ":")
+ if !ok {
+ return sourcedomain.Ref{}, false
+ }
+ value = strings.TrimSpace(value)
+ switch strings.ToLower(strings.TrimSpace(prefix)) {
+ case "note":
+ return sourcedomain.Ref{Kind: sourcedomain.KindNote, Ref: strings.Trim(value, "/")}, true
+ case "zenn":
+ return sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: strings.Trim(value, "/")}, true
+ case "qiita":
+ return sourcedomain.Ref{Kind: sourcedomain.KindQiita, Ref: strings.Trim(value, "/")}, true
+ case "rss":
+ return sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: value}, true
+ case "html":
+ return sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: value}, true
+ default:
+ return sourcedomain.Ref{}, false
+ }
+}
+
+func looksLikeURL(value string) bool {
+ parsed, err := url.Parse(value)
+ return err == nil && parsed.Scheme != "" && parsed.Host != ""
+}
+
+func snapshotsFromArticles(kind sourcedomain.Kind, articles []articledomain.Article) []sourcedomain.ArticleSnapshot {
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, len(articles))
+ for _, article := range articles {
+ snapshots = append(snapshots, snapshotFromArticle(kind, article))
+ }
+ return snapshots
+}
+
+func snapshotFromArticle(kind sourcedomain.Kind, article articledomain.Article) sourcedomain.ArticleSnapshot {
+ return sourcedomain.ArticleSnapshot{
+ ID: firstNonEmpty(article.URL, article.Title),
+ Kind: kind,
+ URL: article.URL,
+ Title: article.Title,
+ Content: article.Content,
+ }
+}
diff --git a/internal/infrastructure/source/rss.go b/internal/infrastructure/source/rss.go
new file mode 100644
index 0000000..0ec2d98
--- /dev/null
+++ b/internal/infrastructure/source/rss.go
@@ -0,0 +1,224 @@
+package source
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+const userAgent = "note-maker/2026.05 (+https://github.com/terisuke/note_maker)"
+
+// RSSFetcher reads RSS 2.0 and Atom feeds.
+type RSSFetcher struct {
+ client *http.Client
+}
+
+// NewRSSFetcher creates a feed fetcher.
+func NewRSSFetcher(client *http.Client) *RSSFetcher {
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ return &RSSFetcher{client: client}
+}
+
+// FetchList returns feed entries in feed order, normally newest first.
+func (f *RSSFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ if err := ref.Validate(); err != nil {
+ return nil, err
+ }
+ if limit <= 0 {
+ limit = 5
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(ref.URL), nil)
+ if err != nil {
+ return nil, fmt.Errorf("create feed request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8")
+ response, err := f.client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("fetch feed: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("fetch feed: unexpected status %s", response.Status)
+ }
+ return parseFeed(response.Body, ref, limit, time.Now().UTC())
+}
+
+// FetchArticle returns the first matching feed item when the ref URL is a feed.
+func (f *RSSFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ items, err := f.FetchList(ctx, ref, 1)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) == 0 {
+ return nil, fmt.Errorf("feed contained no articles")
+ }
+ return &items[0], nil
+}
+
+func parseFeed(reader io.Reader, ref sourcedomain.Ref, limit int, fetchedAt time.Time) ([]sourcedomain.ArticleSnapshot, error) {
+ encoded, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, fmt.Errorf("read feed: %w", err)
+ }
+ var rss rssDocument
+ if err := xml.Unmarshal(encoded, &rss); err == nil && len(rss.Channel.Items) > 0 {
+ return rssSnapshots(rss.Channel.Items, ref, limit, fetchedAt), nil
+ }
+ var atom atomFeed
+ if err := xml.Unmarshal(encoded, &atom); err == nil && len(atom.Entries) > 0 {
+ return atomSnapshots(atom.Entries, ref, limit, fetchedAt), nil
+ }
+ return nil, fmt.Errorf("feed contained no RSS items or Atom entries")
+}
+
+func rssSnapshots(items []rssItem, ref sourcedomain.Ref, limit int, fetchedAt time.Time) []sourcedomain.ArticleSnapshot {
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, min(limit, len(items)))
+ for _, item := range items {
+ if len(snapshots) >= limit {
+ break
+ }
+ link := strings.TrimSpace(item.Link)
+ if link == "" {
+ link = strings.TrimSpace(item.GUID.Value)
+ }
+ content := firstNonEmpty(item.EncodedContent, item.Description)
+ content = htmlToParagraphText(content)
+ if content == "" {
+ continue
+ }
+ publishedAt := parseFeedTime(firstNonEmpty(item.PubDate, item.Date))
+ snapshots = append(snapshots, sourcedomain.ArticleSnapshot{
+ ID: firstNonEmpty(item.GUID.Value, link),
+ Kind: ref.Kind,
+ URL: link,
+ Title: normalizeWhitespace(item.Title),
+ Content: content,
+ PublishedAt: publishedAt,
+ UpdatedAt: publishedAt,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return snapshots
+}
+
+func atomSnapshots(entries []atomEntry, ref sourcedomain.Ref, limit int, fetchedAt time.Time) []sourcedomain.ArticleSnapshot {
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, min(limit, len(entries)))
+ for _, entry := range entries {
+ if len(snapshots) >= limit {
+ break
+ }
+ link := atomEntryLink(entry)
+ content := htmlToParagraphText(cleanXMLText(firstNonEmpty(entry.Content.Value, entry.Summary.Value)))
+ if content == "" {
+ continue
+ }
+ publishedAt := parseFeedTime(firstNonEmpty(entry.Published, entry.Updated))
+ updatedAt := parseFeedTime(entry.Updated)
+ snapshots = append(snapshots, sourcedomain.ArticleSnapshot{
+ ID: firstNonEmpty(entry.ID, link),
+ Kind: ref.Kind,
+ URL: link,
+ Title: normalizeWhitespace(entry.Title),
+ Content: content,
+ PublishedAt: publishedAt,
+ UpdatedAt: updatedAt,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return snapshots
+}
+
+func atomEntryLink(entry atomEntry) string {
+ for _, link := range entry.Links {
+ if strings.TrimSpace(link.Rel) == "" || link.Rel == "alternate" {
+ if strings.TrimSpace(link.Href) != "" {
+ return strings.TrimSpace(link.Href)
+ }
+ }
+ }
+ if len(entry.Links) > 0 {
+ return strings.TrimSpace(entry.Links[0].Href)
+ }
+ return ""
+}
+
+func parseFeedTime(value string) time.Time {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return time.Time{}
+ }
+ layouts := []string{
+ time.RFC1123Z,
+ time.RFC1123,
+ time.RFC3339,
+ time.RFC3339Nano,
+ "Mon, 02 Jan 2006 15:04:05 -0700",
+ "2006-01-02T15:04:05-07:00",
+ "2006-01-02",
+ }
+ for _, layout := range layouts {
+ if parsed, err := time.Parse(layout, value); err == nil {
+ return parsed
+ }
+ }
+ return time.Time{}
+}
+
+type rssDocument struct {
+ Channel struct {
+ Items []rssItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ PubDate string `xml:"pubDate"`
+ Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
+ EncodedContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
+ GUID rssGUID `xml:"guid"`
+}
+
+type rssGUID struct {
+ Value string `xml:",chardata"`
+}
+
+type atomFeed struct {
+ Entries []atomEntry `xml:"entry"`
+}
+
+type atomEntry struct {
+ ID string `xml:"id"`
+ Title string `xml:"title"`
+ Updated string `xml:"updated"`
+ Published string `xml:"published"`
+ Links []atomLink `xml:"link"`
+ Summary atomTextNode `xml:"summary"`
+ Content atomTextNode `xml:"content"`
+}
+
+type atomLink struct {
+ Rel string `xml:"rel,attr"`
+ Href string `xml:"href,attr"`
+}
+
+type atomTextNode struct {
+ Value string `xml:",innerxml"`
+}
+
+func cleanXMLText(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "")
+ return strings.TrimSpace(value)
+}
diff --git a/internal/infrastructure/source/rss_test.go b/internal/infrastructure/source/rss_test.go
new file mode 100644
index 0000000..67e6085
--- /dev/null
+++ b/internal/infrastructure/source/rss_test.go
@@ -0,0 +1,130 @@
+package source
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+func TestRSSFetcherParsesRSS2ContentEncoded(t *testing.T) {
+ feed := `
+
+
+ -
+
最新記事
+ https://example.com/post-1
+ post-1
+ Sat, 02 May 2026 12:00:00 +0900
+ 本文です。
続きです。
]]>
+
+
+`
+ snapshots, err := parseFeed(strings.NewReader(feed), sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: "https://example.com/feed.xml"}, 5, testFetchedAt)
+ if err != nil {
+ t.Fatalf("parse feed: %v", err)
+ }
+ if len(snapshots) != 1 {
+ t.Fatalf("len = %d", len(snapshots))
+ }
+ if snapshots[0].Title != "最新記事" || snapshots[0].Content != "本文です。\n\n続きです。" {
+ t.Fatalf("unexpected snapshot: %#v", snapshots[0])
+ }
+ if snapshots[0].PublishedAt.IsZero() {
+ t.Fatalf("published time was not parsed")
+ }
+}
+
+func TestRSSFetcherParsesAtom(t *testing.T) {
+ feed := `
+
+
+ tag:example.com,2026:1
+ Atom記事
+ 2026-05-02T12:00:00+09:00
+
+ Atom本文
]]>
+
+`
+ snapshots, err := parseFeed(strings.NewReader(feed), sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: "https://example.com/atom.xml"}, 5, testFetchedAt)
+ if err != nil {
+ t.Fatalf("parse atom: %v", err)
+ }
+ if len(snapshots) != 1 || snapshots[0].URL != "https://example.com/atom-1" || snapshots[0].Content != "Atom本文" {
+ t.Fatalf("unexpected snapshots: %#v", snapshots)
+ }
+}
+
+func TestAuthorStyleFetcherRoutesExplicitSources(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/zenn-user/feed":
+ if r.URL.Query().Get("all") != "1" {
+ t.Fatalf("zenn feed should request all=1, got %s", r.URL.RawQuery)
+ }
+ _, _ = w.Write([]byte(`Zenn https://zenn.dev/zenn-user/articles/aZenn本文
]]>`))
+ case "/api/v2/users/qiita-user/items":
+ _, _ = w.Write([]byte(`[{"id":"abc","url":"https://qiita.com/qiita-user/items/abc","title":"Qiita","body":"# Qiita本文","created_at":"2026-05-02T00:00:00+09:00","updated_at":"2026-05-02T00:00:00+09:00"}]`))
+ case "/feed.xml":
+ _, _ = w.Write([]byte(`RSS https://example.com/rssRSS本文]]> `))
+ default:
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ fetcher := NewAuthorStyleFetcherWithClient(mappedClient(server.URL))
+ cases := map[string]string{
+ "zenn:zenn-user": "Zenn本文",
+ "qiita:qiita-user": "# Qiita本文",
+ "rss:https://x/feed.xml": "RSS本文",
+ }
+ for selector, wantContent := range cases {
+ articles, err := fetcher.FetchUserLatestArticles(context.Background(), selector, 3)
+ if err != nil {
+ t.Fatalf("%s: fetch latest: %v", selector, err)
+ }
+ if len(articles) != 1 || articles[0].Content != wantContent {
+ t.Fatalf("%s: unexpected articles: %#v", selector, articles)
+ }
+ }
+}
+
+func TestRefFromURLRoutesKnownHosts(t *testing.T) {
+ tests := map[string]sourcedomain.Kind{
+ "https://note.com/user/n/n1": sourcedomain.KindNote,
+ "https://zenn.dev/user/articles/abc": sourcedomain.KindZenn,
+ "https://qiita.com/user/items/abc": sourcedomain.KindQiita,
+ "https://example.com/rss.xml": sourcedomain.KindRSS,
+ "https://example.com/blog/post": sourcedomain.KindHTML,
+ }
+ for rawURL, want := range tests {
+ if got := RefFromURL(rawURL).Kind; got != want {
+ t.Fatalf("%s kind = %s, want %s", rawURL, got, want)
+ }
+ }
+}
+
+var testFetchedAt = parseFeedTime("2026-05-02T00:00:00+09:00")
+
+func mappedClient(target string) *http.Client {
+ targetURL, _ := url.Parse(target)
+ return &http.Client{Transport: rewriteTransport{target: targetURL, next: http.DefaultTransport}}
+}
+
+type rewriteTransport struct {
+ target *url.URL
+ next http.RoundTripper
+}
+
+func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+ clone := r.Clone(r.Context())
+ clone.URL.Scheme = t.target.Scheme
+ clone.URL.Host = t.target.Host
+ clone.Host = t.target.Host
+ return t.next.RoundTrip(clone)
+}
diff --git a/internal/infrastructure/source/text.go b/internal/infrastructure/source/text.go
new file mode 100644
index 0000000..bb0b1bd
--- /dev/null
+++ b/internal/infrastructure/source/text.go
@@ -0,0 +1,61 @@
+package source
+
+import (
+ "strings"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+func normalizeWhitespace(value string) string {
+ return strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
+}
+
+func normalizeParagraphs(value string) string {
+ lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n"), "\n")
+ normalized := make([]string, 0, len(lines))
+ blank := false
+ for _, line := range lines {
+ line = normalizeWhitespace(line)
+ if line == "" {
+ if !blank {
+ normalized = append(normalized, "")
+ }
+ blank = true
+ continue
+ }
+ blank = false
+ normalized = append(normalized, line)
+ }
+ return strings.TrimSpace(strings.Join(normalized, "\n"))
+}
+
+func htmlToParagraphText(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(value))
+ if err != nil {
+ return normalizeParagraphs(value)
+ }
+ parts := make([]string, 0)
+ doc.Find("h1,h2,h3,h4,p,li,blockquote,pre,code").Each(func(_ int, s *goquery.Selection) {
+ text := normalizeWhitespace(s.Text())
+ if text != "" {
+ parts = append(parts, text)
+ }
+ })
+ if len(parts) == 0 {
+ return normalizeParagraphs(doc.Text())
+ }
+ return normalizeParagraphs(strings.Join(parts, "\n\n"))
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/internal/infrastructure/source/zenn.go b/internal/infrastructure/source/zenn.go
new file mode 100644
index 0000000..5b001ca
--- /dev/null
+++ b/internal/infrastructure/source/zenn.go
@@ -0,0 +1,63 @@
+package source
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+// ZennFetcher reads Zenn public articles through the official RSS feed shape.
+type ZennFetcher struct {
+ rss *RSSFetcher
+ html *HTMLFetcher
+}
+
+// NewZennFetcher creates a Zenn fetcher.
+func NewZennFetcher(client *http.Client) *ZennFetcher {
+ return &ZennFetcher{
+ rss: NewRSSFetcher(client),
+ html: NewHTMLFetcher(client),
+ }
+}
+
+// FetchList fetches a user's public Zenn feed. `all=1` is used so older public
+// posts are not silently hidden by the default feed size.
+func (f *ZennFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ user := strings.Trim(strings.TrimSpace(ref.Ref), "/")
+ if user == "" && strings.TrimSpace(ref.URL) != "" {
+ parsed, err := url.Parse(strings.TrimSpace(ref.URL))
+ if err != nil {
+ return nil, fmt.Errorf("parse zenn url: %w", err)
+ }
+ parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ if len(parts) > 0 {
+ user = parts[0]
+ }
+ }
+ if user == "" {
+ return nil, fmt.Errorf("zenn user ref is required")
+ }
+ feedURL := fmt.Sprintf("https://zenn.dev/%s/feed?all=1", url.PathEscape(user))
+ snapshots, err := f.rss.FetchList(ctx, sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: user, URL: feedURL}, limit)
+ if err != nil {
+ return nil, err
+ }
+ for i := range snapshots {
+ snapshots[i].Kind = sourcedomain.KindZenn
+ }
+ return snapshots, nil
+}
+
+// FetchArticle fetches one Zenn article page.
+func (f *ZennFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ article, err := f.html.FetchArticle(ctx, sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: ref.URL})
+ if err != nil {
+ return nil, err
+ }
+ article.Kind = sourcedomain.KindZenn
+ return article, nil
+}
From 02e01651c2e1b7eb562595750939af0086057691 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sat, 2 May 2026 23:33:20 +0900
Subject: [PATCH 13/33] Close format and persona seed gaps for #23 #24
---
cmd/scenario/format_persona_seed/main.go | 251 ++++++++++++++++++
...02-multi-persona-multi-format-extension.md | 5 +-
.../multi-persona-multi-format.md | 8 +-
...ue-23-24-format-persona-seed-2026-05-02.md | 58 ++++
internal/domain/format/format.go | 62 +++++
internal/domain/format/format_test.go | 57 ++++
internal/domain/persona/persona_test.go | 70 ++++-
7 files changed, 507 insertions(+), 4 deletions(-)
create mode 100644 cmd/scenario/format_persona_seed/main.go
create mode 100644 docs/validation/issue-23-24-format-persona-seed-2026-05-02.md
diff --git a/cmd/scenario/format_persona_seed/main.go b/cmd/scenario/format_persona_seed/main.go
new file mode 100644
index 0000000..4c54dc7
--- /dev/null
+++ b/cmd/scenario/format_persona_seed/main.go
@@ -0,0 +1,251 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ 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"
+)
+
+const defaultOutputDir = "tmp/format_persona_seed"
+
+type scenarioSummary struct {
+ Personas []personaSummary `json:"personas"`
+ Formats []formatSummary `json:"formats"`
+ Samples []sampleSummary `json:"samples"`
+}
+
+type personaSummary struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ DefaultFormat string `json:"default_format"`
+ SourceKinds []string `json:"source_kinds"`
+ PromptHintPath string `json:"prompt_hint_path"`
+}
+
+type formatSummary struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ RequiresMeta bool `json:"requires_meta"`
+ AllowsCode bool `json:"allows_code"`
+ PromptHasGuide bool `json:"prompt_has_guide"`
+ SampleOutputPath string `json:"sample_output_path"`
+}
+
+type sampleSummary struct {
+ Name string `json:"name"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ OutputPath string `json:"output_path"`
+}
+
+func main() {
+ outputDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)
+ if err := os.MkdirAll(outputDir, 0o755); err != nil {
+ fatalf("create output dir: %v", err)
+ }
+
+ personas := personadomain.DefaultRegistry().List()
+ formats := outputformat.DefaultRegistry().List()
+ if len(personas) < 2 {
+ fatalf("expected at least two seeded personas, got %d", len(personas))
+ }
+ if len(formats) != len(sampleDrafts()) {
+ fatalf("sample coverage mismatch: formats=%d samples=%d", len(formats), len(sampleDrafts()))
+ }
+
+ guide := scenarioGuide()
+ summary := scenarioSummary{}
+ seenDefaultFormats := map[string]bool{}
+ for _, persona := range personas {
+ if strings.TrimSpace(persona.DefaultFormat) == "" {
+ fatalf("persona %s has no default format", persona.ID)
+ }
+ if _, ok := outputformat.DefaultRegistry().Get(persona.DefaultFormat); !ok {
+ fatalf("persona %s references unknown default format %s", persona.ID, persona.DefaultFormat)
+ }
+ if len(persona.Sources) == 0 {
+ fatalf("persona %s has no sources", persona.ID)
+ }
+ hint := persona.PromptHint()
+ if hint == "" {
+ fatalf("persona %s has empty prompt hint", persona.ID)
+ }
+ hintPath := filepath.Join(outputDir, persona.ID+"_prompt_hint.md")
+ writeFile(hintPath, hint+"\n")
+ seenDefaultFormats[persona.DefaultFormat] = true
+ summary.Personas = append(summary.Personas, personaSummary{
+ ID: persona.ID,
+ DisplayName: persona.DisplayName,
+ DefaultFormat: persona.DefaultFormat,
+ SourceKinds: sourceKinds(persona),
+ PromptHintPath: hintPath,
+ })
+ }
+ if len(seenDefaultFormats) < 2 {
+ fatalf("seed personas should exercise at least two default formats")
+ }
+
+ defaultPersona := mustPersona(personadomain.IDTerisuke)
+ for _, format := range formats {
+ sample, ok := sampleDrafts()[format.ID]
+ if !ok {
+ fatalf("missing sample draft for format %s", format.ID)
+ }
+ if _, err := articledomain.NewDraftForFormat(sample, format.ID); err != nil {
+ fatalf("sample draft for %s failed validation: %v", format.ID, err)
+ }
+ prompt := draftapp.BuildPromptForMode(guide, scenarioBrief(defaultPersona, format), defaultPersona, format)
+ promptHasGuide := strings.Contains(prompt, "## 媒体別Markdownガイド")
+ if !promptHasGuide {
+ fatalf("prompt for %s did not include embedded format guide", format.ID)
+ }
+ outputPath := filepath.Join(outputDir, format.ID+".md")
+ writeFile(outputPath, sample+"\n")
+ summary.Formats = append(summary.Formats, formatSummary{
+ ID: format.ID,
+ DisplayName: format.DisplayName,
+ RequiresMeta: format.RequiresMeta,
+ AllowsCode: format.AllowsCode,
+ PromptHasGuide: promptHasGuide,
+ SampleOutputPath: outputPath,
+ })
+ }
+
+ for _, sample := range personaFormatSamples() {
+ persona := mustPersona(sample.personaID)
+ format := outputformat.DefaultRegistry().MustGet(sample.formatID)
+ brief := scenarioBrief(persona, format)
+ prompt := draftapp.BuildPromptForMode(guide, brief, persona, format)
+ for _, want := range []string{persona.DisplayName, format.DisplayName, "## 媒体別Markdownガイド"} {
+ if !strings.Contains(prompt, want) {
+ fatalf("%s prompt missing %q", sample.name, want)
+ }
+ }
+ draft := sampleDrafts()[sample.formatID]
+ if _, err := articledomain.NewDraftForFormat(draft, sample.formatID); err != nil {
+ fatalf("%s sample failed validation: %v", sample.name, err)
+ }
+ path := filepath.Join(outputDir, sample.name+".md")
+ writeFile(path, draft+"\n")
+ summary.Samples = append(summary.Samples, sampleSummary{
+ Name: sample.name,
+ PersonaID: sample.personaID,
+ OutputFormatID: sample.formatID,
+ OutputPath: path,
+ })
+ }
+
+ writeJSON(filepath.Join(outputDir, "summary.json"), summary)
+ fmt.Printf("format/persona seed scenario completed\n")
+ fmt.Printf("personas=%d\n", len(summary.Personas))
+ fmt.Printf("formats=%d\n", len(summary.Formats))
+ fmt.Printf("samples=%d\n", len(summary.Samples))
+ fmt.Printf("summary=%s\n", filepath.Join(outputDir, "summary.json"))
+}
+
+func scenarioGuide() authordomain.WritingStyleGuide {
+ return authordomain.WritingStyleGuide{
+ ID: "guide_format_persona_seed",
+ ProfileID: "profile_format_persona_seed",
+ Markdown: "実体験、検証、読者への次の行動を自然につなぐ。",
+ PreferredFirstPerson: "僕",
+ RecurringThemes: []string{"AI", "検証", "発信"},
+ ParagraphRhythm: "短すぎない段落で、経験と判断を接続する。",
+ SentenceRhythm: "読みやすい中程度の文で断定と余白を混ぜる。",
+ HeadingGuidance: "読者が流れを追える具体的な見出しにする。",
+ QuoteGuidance: "印象的な言葉は必要な箇所にだけ置く。",
+ OpeningPatterns: []string{"違和感から始める", "検証結果から始める"},
+ ConclusionPatterns: []string{"次の行動で締める"},
+ }
+}
+
+func scenarioBrief(persona personadomain.Persona, format outputformat.OutputFormat) briefdomain.ArticleBrief {
+ return briefdomain.ArticleBrief{
+ StyleProfileID: "profile_format_persona_seed",
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Theme: "形式別に同じ検証メモを書き分ける",
+ Reader: "AIを使って発信と実装を改善したい開発者",
+ MustInclude: "出力先ごとの記法、検証結果、次の行動",
+ PersonalContext: "音楽家、エンジニア、起業家として検証を積み重ねてきた背景",
+ TargetLengthStructure: "短い導入、具体例、結論",
+ ToneStance: persona.PromptHint(),
+ }
+}
+
+func sampleDrafts() map[string]string {
+ return map[string]string{
+ outputformat.IDNoteArticle: "# 形式で文章の届き方が変わる\n\n## 違和感\n\n同じ検証メモでも、出す場所が変わると読者の期待は変わる。\n\n## 試したこと\n\n- noteでは体験と解釈を中心にした\n- 技術媒体では手順を明確にした\n\n## 次の一歩\n\nまずは出力先を決めてから下書きを始める。",
+ outputformat.IDMarkdownBlog: "---\ntitle: \"形式別プロンプトの検証\"\ndescription: \"出力先ごとの記法と検証観点を分けて下書き品質を安定させる\"\npubDate: 2026-05-02\nauthor: \"Terisuke\"\ncategory: \"engineering\"\ntags: [\"AI\", \"開発\", \"検証\"]\nlang: \"ja\"\nfeatured: false\nisDraft: true\n---\n\n# 形式別プロンプトの検証\n\n出力先ごとのルールを分けると、レビューで見るべき点が明確になる。\n\n```go\nfmt.Println(\"validated\")\n```",
+ outputformat.IDZennArticle: "---\ntitle: \"形式別プロンプトをGoで検証する\"\nemoji: \"🧪\"\ntype: \"tech\"\ntopics: [\"go\", \"ai\", \"prompt\"]\npublished: false\n---\n\n## 実装\n\nZennでは手順とコードを先に読める形にする。\n\n:::message\n媒体ごとの記法を混ぜないことが重要です。\n:::\n\n```diff go\n+fmt.Println(\"zenn\")\n```",
+ outputformat.IDQiitaArticle: "---\ntitle: \"Qiita向けプロンプト検証\"\ntags: [{name: Go, versions: [\"1.24\"]}, {name: AI}]\n---\n\n## 環境\n\nQiitaでは再現手順を先に置く。\n\n:::note warn\nZennの記法を混ぜないようにします。\n:::\n\n```diff_go\n+fmt.Println(\"qiita\")\n```",
+ outputformat.IDHomepageSection: "形式別に伝わる下書きへ 同じ材料でも、出力先に合わせて構成と記法を切り替えることで、読み手が次の行動を取りやすくなります。
相談する ",
+ }
+}
+
+type personaFormatSample struct {
+ name string
+ personaID string
+ formatID string
+}
+
+func personaFormatSamples() []personaFormatSample {
+ return []personaFormatSample{
+ {name: "terisuke_note", personaID: personadomain.IDTerisuke, formatID: outputformat.IDNoteArticle},
+ {name: "terisuke_blog", personaID: personadomain.IDTerisuke, formatID: outputformat.IDMarkdownBlog},
+ {name: "cloudia_zenn", personaID: personadomain.IDCloudia, formatID: outputformat.IDZennArticle},
+ {name: "cloudia_qiita", personaID: personadomain.IDCloudia, formatID: outputformat.IDQiitaArticle},
+ {name: "terisuke_homepage", personaID: personadomain.IDTerisuke, formatID: outputformat.IDHomepageSection},
+ }
+}
+
+func sourceKinds(persona personadomain.Persona) []string {
+ kinds := make([]string, 0, len(persona.Sources))
+ for _, source := range persona.Sources {
+ kinds = append(kinds, source.Kind)
+ }
+ return kinds
+}
+
+func mustPersona(id string) personadomain.Persona {
+ persona, ok := personadomain.DefaultRegistry().Get(id)
+ if !ok {
+ fatalf("missing persona %s", id)
+ }
+ return persona
+}
+
+func writeJSON(path string, value any) {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatalf("encode %s: %v", err)
+ }
+ writeFile(path, string(encoded)+"\n")
+}
+
+func writeFile(path, content string) {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ fatalf("write %s: %v", path, err)
+ }
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 5d8eae0..7a17946 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -208,6 +208,7 @@ Current implementation status as of 2026-05-02:
- Phase A1 is implemented and merged: the interview surface now renders a chat-style transcript, answer bubbles can be edited inline, and edits create child sessions via fork-on-edit while retaining `parent_session_id` lineage ([#17](https://github.com/terisuke/note_maker/issues/17)).
- Phase A4 is implemented and merged: follow-up prompts include parent question, parent answer, and active style guide context; rule-based fallback questions use the same quoted parent-answer prefix; the transcript labels the parent answer as the deep-dive rationale ([#20](https://github.com/terisuke/note_maker/issues/20)). Validation is recorded in [Issue 20 deep-dive rationale validation](../validation/issue-20-deep-dive-rationale-2026-05-02.md).
- Phase A3 is implemented in code: the generated Markdown textarea is editable, preview rendering live-syncs through `marked`, both preview and Markdown tabs can copy content, and `POST /api/drafts/{id}/regenerate-section` rewrites exactly one `## ` subtree while preserving the rest of the draft byte-for-byte ([#19](https://github.com/terisuke/note_maker/issues/19)). Validation is recorded in [Issue 19 section regeneration validation](../validation/issue-19-section-regeneration-2026-05-02.md).
+- Phase B3/B4 are implemented for the in-repo registry surface: all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Deterministic scenario validation is recorded in [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). Live source-derived guide rebuilding for Zenn/Qiita/RSS remains part of source acquisition work in [#22](https://github.com/terisuke/note_maker/issues/22).
Near-term execution order:
@@ -224,8 +225,8 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- A4 — [#20](https://github.com/terisuke/note_maker/issues/20) Surface deep-dive question rationale in prompt and UI
- B1 — [#21](https://github.com/terisuke/note_maker/issues/21) Introduce Persona and OutputFormat domain concepts (registry + strategy). Implemented by the first Phase B PR: `internal/domain/persona`, `internal/domain/format`, API selectors, prompt dispatch, and format-aware draft validation.
- B2 — [#22](https://github.com/terisuke/note_maker/issues/22) Generalize SourceFetcher beyond note.com (Zenn, Qiita, RSS, HTML)
-- B3 — [#23](https://github.com/terisuke/note_maker/issues/23) Format-specific prompt templates and draft validators (note / markdown_blog / zenn / qiita / homepage_section). Partially implemented: validators and embedded format guides exist; scenario samples remain open.
-- B4 — [#24](https://github.com/terisuke/note_maker/issues/24) Seed persona library with `terisuke` and `cloudia` profiles
+- B3 — [#23](https://github.com/terisuke/note_maker/issues/23) Format-specific prompt templates and draft validators (note / markdown_blog / zenn / qiita / homepage_section). Implemented: validators, embedded format guides, prompt injection, and deterministic scenario samples exist.
+- 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))
- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 894f44e..2dd7f1a 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -32,6 +32,8 @@ Current status after the 2026-05-02 merges:
- [#11](https://github.com/terisuke/note_maker/issues/11) strict Terisuke style tuning is closed.
- [#21](https://github.com/terisuke/note_maker/issues/21) B1 landed early: persona/format domain concepts, prompt dispatch, selectors, and validators exist.
+- [#23](https://github.com/terisuke/note_maker/issues/23) is implemented for the in-repo generation surface: every registered format has a prompt fragment, embedded guide, validator, unit coverage, and deterministic sample validation.
+- [#24](https://github.com/terisuke/note_maker/issues/24) is implemented for built-in seeds: `terisuke` and `cloudia` have distinct source bundles, default formats, prompt hints, and unit/scenario coverage. Live source-derived guide rebuilding remains dependent on [#22](https://github.com/terisuke/note_maker/issues/22).
- [#38](https://github.com/terisuke/note_maker/issues/38) Tailnet OpenAI-compatible API is now the Evo X2 primary path. SSH tunnel access is diagnostic-only.
- [#36](https://github.com/terisuke/note_maker/issues/36) remains open for local llama.cpp fallback quality; it does not block Phase A work.
- [#40](https://github.com/terisuke/note_maker/issues/40) tracks primary Tailnet Evo X2 quality and runtime-metric stabilization.
@@ -187,6 +189,8 @@ Acceptance:
- Each registered format has an embedded Markdown guide injected into the final draft prompt.
- Generating the same brief under different formats produces visibly different drafts: Zenn has frontmatter + many code fences; note has narrative paragraphs and ですます調; homepage_section is HTML with no `# `.
+Implementation note as of 2026-05-02: #23 is implemented for deterministic generation validation. `cmd/scenario/format_persona_seed` writes one validated sample per registered format and confirms that prompt construction injects the selected embedded guide. The scenario avoids live LLM calls so it can run in `go test ./...`-adjacent validation without touching source fetchers.
+
### B4 — Persona library seed
Seed file: `internal/domain/persona/seed.go` (or YAML under `data/personas/`).
@@ -226,6 +230,8 @@ Acceptance:
- A scenario command runs `analyze` for both personas and writes two distinct `WritingStyleGuide` files to `tmp/personas/`.
- Cross-style score: rebuilding Cloudia's guide from Terisuke's articles produces lower style-similarity than Cloudia's own articles (sanity check that the personas are actually distinct).
+Implementation note as of 2026-05-02: #24's built-in seed library is implemented and validated without expanding source acquisition. The registry ships `terisuke` and `cloudia` with distinct default formats, source kinds, first-person options, title patterns, and anti-patterns. Source-derived guide rebuilding for Zenn/Qiita/RSS remains blocked on #22, so the original live-analyze scenario acceptance moves with that source-fetcher work rather than being claimed here.
+
### B5 — Format- and persona-aware fixed questions
The fixed nine questions in `static/js/script.js` are extracted server-side into `internal/domain/brief/questions/`:
@@ -316,4 +322,4 @@ Draft generation now includes a lightweight final verification pass before retur
## Immediate next implementation step
-Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#29](https://github.com/terisuke/note_maker/issues/29) are filed. [#18](https://github.com/terisuke/note_maker/issues/18), [#17](https://github.com/terisuke/note_maker/issues/17), and [#20](https://github.com/terisuke/note_maker/issues/20) are merged. [#19](https://github.com/terisuke/note_maker/issues/19) is implemented in code; after merging it, continue with Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) so editable draft state and regeneration history survive restarts.
+Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#29](https://github.com/terisuke/note_maker/issues/29) are filed. [#18](https://github.com/terisuke/note_maker/issues/18), [#17](https://github.com/terisuke/note_maker/issues/17), and [#20](https://github.com/terisuke/note_maker/issues/20) are merged. [#19](https://github.com/terisuke/note_maker/issues/19) is implemented in code. [#23](https://github.com/terisuke/note_maker/issues/23) and [#24](https://github.com/terisuke/note_maker/issues/24) are implemented for the registry/prompt/validator/seed scope and validated in [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). Continue with Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) so editable draft state and regeneration history survive restarts; source acquisition remains under [#22](https://github.com/terisuke/note_maker/issues/22).
diff --git a/docs/validation/issue-23-24-format-persona-seed-2026-05-02.md b/docs/validation/issue-23-24-format-persona-seed-2026-05-02.md
new file mode 100644
index 0000000..3d4d8c1
--- /dev/null
+++ b/docs/validation/issue-23-24-format-persona-seed-2026-05-02.md
@@ -0,0 +1,58 @@
+# Issue 23/24 format and persona seed validation
+
+Date: 2026-05-02
+
+## Scope
+
+This validation covers:
+
+- [#23](https://github.com/terisuke/note_maker/issues/23) format-specific prompt templates and validators.
+- [#24](https://github.com/terisuke/note_maker/issues/24) seeded persona library for `terisuke` and `cloudia`.
+
+It deliberately does not cover source-fetcher generalisation ([#22](https://github.com/terisuke/note_maker/issues/22)), SQLite persistence ([#26](https://github.com/terisuke/note_maker/issues/26)), or handler coverage expansion ([#29](https://github.com/terisuke/note_maker/issues/29)).
+
+## What changed
+
+- Strengthened `internal/domain/format` validators:
+ - note rejects HTML blocks in addition to platform-specific notation and filename/diff fences.
+ - Zenn requires boolean `published`, inline `topics` with at most five items, and rejects HTML details.
+ - Qiita rejects empty `tags`.
+ - homepage sections reject frontmatter, code fences, and Markdown headings.
+- Added unit coverage for the stricter format cases.
+- Added unit coverage proving seeded personas reference registered default formats and expected source kinds.
+- Added deterministic scenario command `cmd/scenario/format_persona_seed`.
+
+## Scenario
+
+Command:
+
+```sh
+go run ./cmd/scenario/format_persona_seed
+```
+
+Result:
+
+```text
+format/persona seed scenario completed
+personas=2
+formats=5
+samples=5
+summary=tmp/format_persona_seed/summary.json
+```
+
+The scenario validates all registered format samples through `article.NewDraftForFormat`, checks embedded format-guide injection through `draft.BuildPromptForMode`, writes prompt hints for both seeded personas, and exercises these persona/format samples:
+
+- `terisuke_note`
+- `terisuke_blog`
+- `cloudia_zenn`
+- `cloudia_qiita`
+- `terisuke_homepage`
+
+## Acceptance status
+
+- Each validator has positive and negative unit coverage: done.
+- Each registered format has an embedded guide injected into the draft prompt: done.
+- Same writing brief can be represented under visibly different target surfaces: covered by deterministic scenario samples.
+- Seed library includes `terisuke` and `cloudia`, with distinct default formats, source bundles, first-person hints, title patterns, and anti-patterns: done.
+
+Live source-derived guide rebuilding for Zenn/Qiita/RSS remains dependent on [#22](https://github.com/terisuke/note_maker/issues/22), so it is not part of this validation.
diff --git a/internal/domain/format/format.go b/internal/domain/format/format.go
index 4d24059..32846f0 100644
--- a/internal/domain/format/format.go
+++ b/internal/domain/format/format.go
@@ -174,6 +174,9 @@ func (NoteValidator) Validate(markdown string) error {
if containsAny(markdown, []string{":::message", ":::note", ":::details", "@[card]", "@[gist]", "$$", " 0 && len(entries) > limit {
+ return fmt.Errorf("%s must contain at most %d items", key, limit)
+ }
+ return nil
+}
+
+func splitInlineYAMLList(value string) []string {
+ rawItems := strings.Split(value, ",")
+ items := make([]string, 0, len(rawItems))
+ for _, item := range rawItems {
+ item = strings.TrimSpace(strings.Trim(item, `"'`))
+ if item != "" {
+ items = append(items, item)
+ }
+ }
+ return items
+}
+
+func hasNonEmptyYAMLValue(frontmatter, key string) bool {
+ inlinePattern := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `:\s*(.+)\s*$`)
+ if match := inlinePattern.FindStringSubmatch(frontmatter); len(match) == 2 {
+ value := strings.TrimSpace(match[1])
+ return value != "" && value != "[]" && value != "{}"
+ }
+ blockPattern := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `:\s*\n\s*-\s+\S+`)
+ return blockPattern.MatchString(frontmatter)
+}
+
func containsAny(value string, needles []string) bool {
lower := strings.ToLower(value)
for _, needle := range needles {
diff --git a/internal/domain/format/format_test.go b/internal/domain/format/format_test.go
index dcf83b2..e785171 100644
--- a/internal/domain/format/format_test.go
+++ b/internal/domain/format/format_test.go
@@ -89,6 +89,11 @@ func TestPlatformSpecificNotationIsNotMixed(t *testing.T) {
if err := (NoteValidator{}).Validate(noteWithFilenameFence); err == nil {
t.Fatal("expected note validator to reject filename code fence")
}
+
+ noteWithHTML := "# タイトル\n\n"
+ if err := (NoteValidator{}).Validate(noteWithHTML); err == nil {
+ t.Fatal("expected note validator to reject HTML blocks")
+ }
}
func TestMarkdownBlogRejectsUnsupportedCorBlogMetadata(t *testing.T) {
@@ -109,3 +114,55 @@ featured: false
t.Fatal("expected invalid company blog metadata to be rejected")
}
}
+
+func TestZennValidatorEnforcesMetadataShape(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ }{
+ {
+ name: "published is boolean",
+ input: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: \"no\"\n---\n\n## 本文",
+ },
+ {
+ name: "topics are inline list",
+ input: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics:\n - go\npublished: false\n---\n\n## 本文",
+ },
+ {
+ name: "topics capped at five",
+ input: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\", \"ai\", \"llm\", \"zenn\", \"test\", \"cli\"]\npublished: false\n---\n\n## 本文",
+ },
+ {
+ name: "html details is qiita style",
+ input: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n詳細 ",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := (ZennValidator{}).Validate(tt.input); err == nil {
+ t.Fatal("expected invalid zenn article to be rejected")
+ }
+ })
+ }
+}
+
+func TestQiitaValidatorRejectsEmptyTags(t *testing.T) {
+ invalid := "---\ntitle: \"T\"\ntags: []\n---\n\n## 本文"
+ if err := (QiitaValidator{}).Validate(invalid); err == nil {
+ t.Fatal("expected empty Qiita tags to be rejected")
+ }
+}
+
+func TestHomepageSectionRejectsMarkdownScaffolding(t *testing.T) {
+ tests := []string{
+ "---\ntitle: \"T\"\n---\n",
+ "\n\n```html\n余分
\n```",
+ "",
+ }
+ for _, input := range tests {
+ if err := (HomepageSectionValidator{}).Validate(input); err == nil {
+ t.Fatalf("expected homepage section to reject:\n%s", input)
+ }
+ }
+}
diff --git a/internal/domain/persona/persona_test.go b/internal/domain/persona/persona_test.go
index 25be8ab..a73e229 100644
--- a/internal/domain/persona/persona_test.go
+++ b/internal/domain/persona/persona_test.go
@@ -1,6 +1,10 @@
package persona
-import "testing"
+import (
+ "testing"
+
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+)
func TestDefaultRegistryContainsSeedPersonas(t *testing.T) {
registry := DefaultRegistry()
@@ -25,3 +29,67 @@ func TestPersonasKeepVoicesSeparate(t *testing.T) {
t.Fatal("seed personas should have distinct prompt hints")
}
}
+
+func TestSeedPersonasReferenceRegisteredFormatsAndExpectedSources(t *testing.T) {
+ registry := DefaultRegistry()
+ tests := []struct {
+ personaID string
+ defaultFormatID string
+ sourceKinds []string
+ firstPerson string
+ }{
+ {
+ personaID: IDTerisuke,
+ defaultFormatID: outputformat.IDNoteArticle,
+ sourceKinds: []string{"note", "rss", "github"},
+ firstPerson: "僕",
+ },
+ {
+ personaID: IDCloudia,
+ defaultFormatID: outputformat.IDZennArticle,
+ sourceKinds: []string{"zenn", "qiita"},
+ firstPerson: "クラウディア",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.personaID, func(t *testing.T) {
+ item, ok := registry.Get(tt.personaID)
+ if !ok {
+ t.Fatalf("missing persona %s", tt.personaID)
+ }
+ if item.DefaultFormat != tt.defaultFormatID {
+ t.Fatalf("default format = %s, want %s", item.DefaultFormat, tt.defaultFormatID)
+ }
+ if _, ok := outputformat.DefaultRegistry().Get(item.DefaultFormat); !ok {
+ t.Fatalf("persona %s references unregistered format %s", item.ID, item.DefaultFormat)
+ }
+ for _, wantKind := range tt.sourceKinds {
+ if !hasSourceKind(item, wantKind) {
+ t.Fatalf("persona %s missing source kind %s", item.ID, wantKind)
+ }
+ }
+ if !contains(item.VoiceNotes.FirstPerson, tt.firstPerson) {
+ t.Fatalf("persona %s missing first person %s", item.ID, tt.firstPerson)
+ }
+ })
+ }
+}
+
+func hasSourceKind(item Persona, kind string) bool {
+ for _, source := range item.Sources {
+ if source.Kind == kind {
+ return true
+ }
+ }
+ return false
+}
+
+func contains(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
From aaba7bd78f5ac5f218cfec5ceb299cd5f0ba8b5d Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sat, 2 May 2026 23:38:47 +0900
Subject: [PATCH 14/33] Add workflow handler coverage for #29
---
internal/handlers/workflow_handler_test.go | 408 +++++++++++++++++++++
1 file changed, 408 insertions(+)
create mode 100644 internal/handlers/workflow_handler_test.go
diff --git a/internal/handlers/workflow_handler_test.go b/internal/handlers/workflow_handler_test.go
new file mode 100644
index 0000000..613209d
--- /dev/null
+++ b/internal/handlers/workflow_handler_test.go
@@ -0,0 +1,408 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gorilla/mux"
+ authorstyleapp "github.com/teradakousuke/note_maker/internal/application/authorstyle"
+ 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"
+ "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+)
+
+func TestSeedAuthorStyleHandlerStoresPresetAndGetAuthorStyle(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{"persona_id":"terisuke"}`))
+ response := httptest.NewRecorder()
+
+ SeedAuthorStyleHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("seed status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var seeded authorStyleResponse
+ if err := json.NewDecoder(response.Body).Decode(&seeded); err != nil {
+ t.Fatalf("decode seed response: %v", err)
+ }
+ if seeded.ProfileID == "" || seeded.GuideID == "" || !strings.Contains(seeded.GuideMarkdown, "一人称") {
+ t.Fatalf("unexpected seeded style response: %#v", seeded)
+ }
+
+ getRequest := httptest.NewRequest(http.MethodGet, "/api/author-styles/"+seeded.ProfileID, nil)
+ getRequest = mux.SetURLVars(getRequest, map[string]string{"id": seeded.ProfileID})
+ getResponse := httptest.NewRecorder()
+
+ GetAuthorStyleHandler(getResponse, getRequest)
+
+ if getResponse.Code != http.StatusOK {
+ t.Fatalf("get status = %d, body = %s", getResponse.Code, getResponse.Body.String())
+ }
+ var fetched authorStyleResponse
+ if err := json.NewDecoder(getResponse.Body).Decode(&fetched); err != nil {
+ t.Fatalf("decode get response: %v", err)
+ }
+ if fetched.ID != seeded.ID || fetched.ProfileID != seeded.ProfileID {
+ t.Fatalf("fetched style = %#v, seeded = %#v", fetched, seeded)
+ }
+}
+
+func TestSeedAuthorStyleHandlerRejectsUnknownPersona(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{"persona_id":"missing"}`))
+ response := httptest.NewRecorder()
+
+ SeedAuthorStyleHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusBadRequest, "UNKNOWN_PERSONA")
+}
+
+func TestGetAuthorStyleHandlerNotFound(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodGet, "/api/author-styles/missing", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ GetAuthorStyleHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusNotFound, "AUTHOR_STYLE_NOT_FOUND")
+}
+
+func TestCreateBriefSessionHandlerCreatesAndPersistsSession(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-create"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions", bytes.NewBufferString(body))
+ response := httptest.NewRecorder()
+
+ CreateBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefSessionResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.SessionID != "session-create" || payload.StyleProfileID != style.Profile.ID {
+ t.Fatalf("unexpected session response: %#v", payload)
+ }
+ if payload.PersonaID != personadomain.IDTerisuke || payload.OutputFormatID != outputformat.IDNoteArticle {
+ t.Fatalf("unexpected defaults: %#v", payload)
+ }
+ if payload.NextQuestion == nil || payload.NextQuestion.ID != briefdomain.QuestionIDTheme {
+ t.Fatalf("next question = %#v", payload.NextQuestion)
+ }
+ if _, ok := workflowStore.GetSession("session-create"); !ok {
+ t.Fatal("created session was not saved")
+ }
+}
+
+func TestCreateBriefSessionHandlerValidatesInputs(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ tests := []struct {
+ name string
+ body string
+ status int
+ code string
+ }{
+ {
+ name: "missing style",
+ body: `{}`,
+ status: http.StatusBadRequest,
+ code: "MISSING_REQUIRED_FIELD",
+ },
+ {
+ name: "unknown style",
+ body: `{"style_profile_id":"missing","session_id":"session-unknown-style"}`,
+ status: http.StatusNotFound,
+ code: "AUTHOR_STYLE_NOT_FOUND",
+ },
+ {
+ name: "unknown persona",
+ body: `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-unknown-persona","persona_id":"missing"}`,
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_PERSONA",
+ },
+ {
+ name: "unknown output format",
+ body: `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-unknown-format","output_format_id":"missing"}`,
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_OUTPUT_FORMAT",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions", bytes.NewBufferString(tt.body))
+ response := httptest.NewRecorder()
+
+ CreateBriefSessionHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
+
+func TestGetBriefSessionHandlerReturnsStoredProgressAndCompletedBrief(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-completed", style.Profile.ID)
+ session.MarkDeepDiveSkipped()
+ brief, err := session.Complete()
+ if err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ if err := workflowStore.SaveBrief(session.ID, brief); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/brief-sessions/session-completed", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "session-completed"})
+ response := httptest.NewRecorder()
+
+ GetBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefSessionResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !payload.Completed || payload.Brief == nil || payload.Brief.Theme != "Local workflow tests" {
+ t.Fatalf("unexpected completed response: %#v", payload)
+ }
+}
+
+func TestAnswerBriefSessionHandlerRecordsAnswerWithoutLLM(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session, err := briefdomain.NewArticleBriefSession("session-answer", style.Profile.ID)
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/session-answer/answers", bytes.NewBufferString(`{"content":"Local workflow coverage"}`))
+ request = mux.SetURLVars(request, map[string]string{"id": "session-answer"})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefSessionResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.Completed || payload.NextQuestion == nil || payload.NextQuestion.ID != briefdomain.QuestionIDOpeningEpisode {
+ t.Fatalf("unexpected answer response: %#v", payload)
+ }
+ saved, ok := workflowStore.GetSession("session-answer")
+ if !ok {
+ t.Fatal("answered session was not saved")
+ }
+ if len(saved.Answers) != 1 || saved.Answers[0].QuestionID != briefdomain.QuestionIDTheme {
+ t.Fatalf("saved answers = %#v", saved.Answers)
+ }
+}
+
+func TestAnswerBriefSessionHandlerSkipDeepDiveCompletesAndSavesBrief(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-skip", style.Profile.ID)
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/session-skip/answers", bytes.NewBufferString(`{"skip_deep_dive":true}`))
+ request = mux.SetURLVars(request, map[string]string{"id": "session-skip"})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefSessionResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !payload.Completed || payload.Brief == nil || payload.Brief.Theme != "Local workflow tests" {
+ t.Fatalf("unexpected skip response: %#v", payload)
+ }
+ if savedBrief, ok := workflowStore.GetBrief("session-skip"); !ok || savedBrief.Theme != "Local workflow tests" {
+ t.Fatalf("saved brief = %#v, ok = %v", savedBrief, ok)
+ }
+}
+
+func TestAnswerBriefSessionHandlerStreamsFirstAnswerWithoutLLM(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session, err := briefdomain.NewArticleBriefSession("session-stream-answer", style.Profile.ID)
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/session-stream-answer/answers", bytes.NewBufferString(`{"content":"Streaming answer"}`))
+ request.Header.Set("Accept", "text/event-stream")
+ request = mux.SetURLVars(request, map[string]string{"id": "session-stream-answer"})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if got := response.Header().Get("Content-Type"); !strings.Contains(got, "text/event-stream") {
+ t.Fatalf("unexpected content type: %s", got)
+ }
+ stream := response.Body.String()
+ for _, want := range []string{"event: status", "stream_opened", "event: result", "event: done"} {
+ if !strings.Contains(stream, want) {
+ t.Fatalf("stream missing %q:\n%s", want, stream)
+ }
+ }
+}
+
+func TestAnswerBriefSessionHandlerRejectsMissingSession(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/missing/answers", bytes.NewBufferString(`{"content":"answer"}`))
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusNotFound, "BRIEF_SESSION_NOT_FOUND")
+}
+
+func TestGenerateDraftHandlerValidatesStoredContextBeforeLLM(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ if err := workflowStore.SaveBrief("session-draft", briefdomain.ArticleBrief{
+ StyleProfileID: style.Profile.ID,
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ Theme: "Draft validation",
+ Reader: "Developers",
+ MustInclude: "No LLM call",
+ TargetLengthStructure: "1200字",
+ }); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+ tests := []struct {
+ name string
+ body string
+ status int
+ code string
+ }{
+ {
+ name: "style not found",
+ body: `{"style_profile_id":"missing","session_id":"session-draft"}`,
+ status: http.StatusNotFound,
+ code: "AUTHOR_STYLE_NOT_FOUND",
+ },
+ {
+ name: "brief not found",
+ body: `{"style_profile_id":"` + style.Profile.ID + `","session_id":"missing"}`,
+ status: http.StatusBadRequest,
+ code: "BRIEF_NOT_FOUND",
+ },
+ {
+ name: "unknown persona",
+ body: `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-draft","persona_id":"missing"}`,
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_PERSONA",
+ },
+ {
+ name: "unknown output format",
+ body: `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-draft","output_format_id":"missing"}`,
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_OUTPUT_FORMAT",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBufferString(tt.body))
+ response := httptest.NewRecorder()
+
+ GenerateDraftHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
+
+func TestRegenerateDraftSectionHandlerValidatesContextBeforeLLM(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts/missing/regenerate-section", bytes.NewBufferString(`{"draft_markdown":"# Title","section_anchor":"Title"}`))
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ RegenerateDraftSectionHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusBadRequest, "DRAFT_CONTEXT_NOT_FOUND")
+}
+
+func setupWorkflowStyle(t *testing.T) authorstyleapp.AnalyzeResult {
+ t.Helper()
+ workflowStore = memory.NewWorkflowStore()
+ persona, ok := personadomain.DefaultRegistry().Get(personadomain.IDTerisuke)
+ if !ok {
+ t.Fatal("missing terisuke persona")
+ }
+ style, err := buildPresetAuthorStyle(persona)
+ if err != nil {
+ t.Fatalf("build style: %v", err)
+ }
+ if err := workflowStore.SaveAuthorStyle(style); err != nil {
+ t.Fatalf("save style: %v", err)
+ }
+ return style
+}
+
+func sessionWithFixedAnswers(t *testing.T, id, styleProfileID string) briefdomain.ArticleBriefSession {
+ t.Helper()
+ session, err := briefdomain.NewArticleBriefSession(id, styleProfileID)
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ answers := []string{
+ "Local workflow tests",
+ "A handler test failed before reaching a networked LLM.",
+ "Maintainers adding coverage.",
+ "Keep endpoint behavior stable.",
+ "HTTP status codes and persistence checks.",
+ "Testing the workflow handler without external services.",
+ "Avoid broad source changes.",
+ "1200字, validation focused.",
+ "Practical and concise.",
+ }
+ for _, answer := range answers {
+ if _, err := session.RecordAnswer(answer); err != nil {
+ t.Fatalf("record answer %q: %v", answer, err)
+ }
+ }
+ return session
+}
+
+func assertErrorResponse(t *testing.T, response *httptest.ResponseRecorder, status int, code string) {
+ t.Helper()
+ if response.Code != status {
+ t.Fatalf("status = %d, want %d, body = %s", response.Code, status, response.Body.String())
+ }
+ var payload ErrorResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode error response: %v", err)
+ }
+ if payload.Error.Code != code {
+ t.Fatalf("error code = %q, want %q; payload = %#v", payload.Error.Code, code, payload)
+ }
+}
From 48db8a84435fe402aaca4d92c017f4e9418495dd Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sat, 2 May 2026 23:57:53 +0900
Subject: [PATCH 15/33] Fix historical source fetching for issue 22
---
cmd/scenario/source_fetch/main.go | 39 ++-
...02-multi-persona-multi-format-extension.md | 2 +-
.../multi-persona-multi-format.md | 3 +-
.../issue-22-source-fetchers-2026-05-02.md | 44 ++-
internal/domain/source/source.go | 13 +-
internal/infrastructure/source/github.go | 285 ++++++++++++++++++
internal/infrastructure/source/router.go | 33 +-
internal/infrastructure/source/rss_test.go | 31 +-
internal/infrastructure/source/text.go | 10 +
internal/infrastructure/source/zenn.go | 79 ++++-
10 files changed, 481 insertions(+), 58 deletions(-)
create mode 100644 internal/infrastructure/source/github.go
diff --git a/cmd/scenario/source_fetch/main.go b/cmd/scenario/source_fetch/main.go
index 48f9183..ec95eff 100644
--- a/cmd/scenario/source_fetch/main.go
+++ b/cmd/scenario/source_fetch/main.go
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -27,24 +28,28 @@ func main() {
fatalf("create output dir: %v", err)
}
- limit := 1
+ limit := envIntOrDefault("SOURCE_FETCH_LIMIT", 20)
fetcher := sourcefetch.NewAuthorStyleFetcherWithClient(&http.Client{Timeout: 20 * time.Second})
- selectors := map[string]string{
- "note": envOrDefault("SOURCE_FETCH_NOTE", "note:cor_instrument"),
- "zenn": envOrDefault("SOURCE_FETCH_ZENN", "zenn:zenn"),
- "qiita": envOrDefault("SOURCE_FETCH_QIITA", "qiita:Qiita"),
- "rss": envOrDefault("SOURCE_FETCH_RSS", "rss:https://zenn.dev/zenn/feed"),
+ selectors := []struct {
+ name string
+ selector string
+ }{
+ {"note", envOrDefault("SOURCE_FETCH_NOTE", "note:cor_instrument")},
+ {"zenn", envOrDefault("SOURCE_FETCH_ZENN", "zenn:cloudia")},
+ {"qiita", envOrDefault("SOURCE_FETCH_QIITA", "qiita:Cloudia_Cor_Inc")},
+ {"rss", envOrDefault("SOURCE_FETCH_RSS", "rss:https://cor-jp.com/rss.xml")},
+ {"github", envOrDefault("SOURCE_FETCH_GITHUB", "github:Cor-Incorporated/corsweb2024/src/content/blog/ja")},
}
- for name, selector := range selectors {
+ for _, source := range selectors {
started := time.Now()
- articles, err := fetcher.FetchUserLatestArticles(ctx, selector, limit)
+ articles, err := fetcher.FetchUserLatestArticles(ctx, source.selector, limit)
if err != nil {
- fatalf("fetch %s (%s): %v", name, selector, err)
+ fatalf("fetch %s (%s): %v", source.name, source.selector, err)
}
- path := filepath.Join(outputDir, name+".json")
+ path := filepath.Join(outputDir, source.name+".json")
writeJSON(path, articles)
- fmt.Printf("%s selector=%s articles=%d elapsed_seconds=%.2f output=%s\n", name, selector, len(articles), time.Since(started).Seconds(), path)
+ fmt.Printf("%s selector=%s limit=%d articles=%d elapsed_seconds=%.2f output=%s\n", source.name, source.selector, limit, len(articles), time.Since(started).Seconds(), path)
}
}
@@ -65,6 +70,18 @@ func envOrDefault(key, fallback string) string {
return fallback
}
+func envIntOrDefault(key string, fallback int) int {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 7a17946..4c2d078 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -148,7 +148,7 @@ New domain types under `internal/domain`:
## Infrastructure Changes
- `internal/infrastructure/repository/sqlite` — new package implementing every repository interface; the JSON file repository becomes an export/import utility for portability.
-- `internal/infrastructure/source/{note,zenn,qiita,rss,html}` — per-source fetchers behind a common interface in `internal/domain/source` (or kept under `infrastructure` and bound by interface in `domain/persona`).
+- `internal/infrastructure/source/{note,zenn,qiita,rss,html,github}` — per-source fetchers behind a common interface in `internal/domain/source` (or kept under `infrastructure` and bound by interface in `domain/persona`). GitHub-backed Markdown is required for Cor.inc blog because the public RSS feed is a discovery source with summaries, while `corsweb2024/src/content/blog/ja/*.md` is the canonical full-body source.
- `internal/infrastructure/llamacpp` — gains streaming (SSE) support; existing non-streaming path retained for tests.
## API Changes
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 2dd7f1a..9e6240d 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -155,12 +155,13 @@ Concrete implementations under `internal/infrastructure/source/`:
- `qiita/` — public REST API (no auth needed for read-only public posts) + HTML fallback.
- `rss/` — generic RSS reader for Astro/Jekyll/Hugo blogs.
- `html/` — generic semantic-content extractor (last resort).
+- `github/` — public repository Markdown reader for canonical blog sources such as `corsweb2024/src/content/blog/ja/*.md`.
Each fetcher carries its own User-Agent string and rate-limit policy.
Acceptance:
-- Scenario test fetches one article from each of {note, zenn, qiita, rss} and produces `tmp/source_fetch/{name}.json`.
+- Scenario test fetches historical user/account material from {note, zenn, qiita, rss, github}; it must prove that Zenn and Qiita return multiple Cloudia articles with body text, and that Cor.inc blog style analysis uses GitHub Markdown because RSS only contains short descriptions.
- The note.com host check moves out of the application service into the `note` fetcher only; other hosts route to other fetchers.
### B3 — Format-specific prompt templates and validators
diff --git a/docs/validation/issue-22-source-fetchers-2026-05-02.md b/docs/validation/issue-22-source-fetchers-2026-05-02.md
index c3716d6..91a5586 100644
--- a/docs/validation/issue-22-source-fetchers-2026-05-02.md
+++ b/docs/validation/issue-22-source-fetchers-2026-05-02.md
@@ -4,7 +4,9 @@ Date: 2026-05-02
## Scope
-Validate [#22](https://github.com/terisuke/note_maker/issues/22): route source acquisition beyond note.com to Zenn, Qiita, RSS/Atom, and generic HTML while keeping normal tests offline.
+Validate [#22](https://github.com/terisuke/note_maker/issues/22): route source acquisition beyond note.com to Zenn, Qiita, RSS/Atom, generic HTML, and GitHub-backed Markdown while keeping normal tests offline.
+
+Correction note: the first validation pass only proved one current item per source. That was not enough for Cloudia/Zenn/Qiita/Cor blog style analysis. This document now records the 2026-05-02 corrective validation against historical user/account sources with a high limit and body-length checks.
## Current source facts checked
@@ -12,7 +14,10 @@ Validate [#22](https://github.com/terisuke/note_maker/issues/22): route source a
- Qiita API v2 documents unauthenticated access as limited to `60` requests per hour per IP address, so the fetcher uses one request per user-list scenario and no polling loop.
- Zenn's official RSS article documents user feeds as `https://zenn.dev/ユーザー名/feed`.
- Zenn's official RSS article documents `?all=1` for including all public posts instead of the default limited feed, so the Zenn fetcher uses that query for user lists.
+- Zenn user RSS is a list source. The implementation now expands each feed item URL through the article page's embedded Next.js data so style analysis receives full article body text, not feed summaries.
- RSS/Atom parsing is implemented through standard XML feeds, with RSS 2.0 item fields and Atom entries handled in one `RSSFetcher`.
+- GitHub's repository contents API can read public repository files without authentication. Cor.inc blog Markdown is therefore fetched from `Cor-Incorporated/corsweb2024/src/content/blog/ja/*.md`, preserving Astro frontmatter and body Markdown.
+- Cor.inc `https://cor-jp.com/rss.xml` is useful for discovery and ordering, but it only contains short descriptions. The canonical full body source for company blog style analysis is GitHub Markdown.
## Implementation
@@ -23,7 +28,9 @@ Validate [#22](https://github.com/terisuke/note_maker/issues/22): route source a
- `qiita:` and qiita.com URLs go to Qiita API v2 fetchers.
- `rss:` and feed-like URLs go to RSS/Atom parsing.
- `html:` and unknown article URLs go to semantic HTML extraction.
+ - `github://` and GitHub/blob/raw URLs go to GitHub repository Markdown fetching.
- `AnalyzeAuthorStyleHandler` and the legacy article-generation handler now use the router through an adapter that still satisfies the existing `FetchArticle` / `FetchUserLatestArticles` application interface.
+- `cmd/scenario/source_fetch` accepts `SOURCE_FETCH_LIMIT`; the default is now multi-item validation rather than a one-item smoke test.
## Scenario
@@ -31,28 +38,32 @@ Command:
```bash
RUN_SOURCE_FETCH_SCENARIO=1 \
-SCENARIO_OUTPUT_DIR=tmp/source_fetch_issue22 \
+SCENARIO_OUTPUT_DIR=tmp/source_fetch_cloudia_history \
+SOURCE_FETCH_LIMIT=100 \
SOURCE_FETCH_NOTE=note:cor_instrument \
-SOURCE_FETCH_ZENN=zenn:zenn \
-SOURCE_FETCH_QIITA=qiita:Qiita \
-SOURCE_FETCH_RSS=rss:https://zenn.dev/zenn/feed \
+SOURCE_FETCH_ZENN=zenn:cloudia \
+SOURCE_FETCH_QIITA=qiita:Cloudia_Cor_Inc \
+SOURCE_FETCH_RSS=rss:https://cor-jp.com/rss.xml \
+SOURCE_FETCH_GITHUB=github:Cor-Incorporated/corsweb2024/src/content/blog/ja \
go run ./cmd/scenario/source_fetch
```
Result:
-| Source | Selector | Articles | Elapsed | Output |
-|---|---|---:|---:|---|
-| Qiita | `qiita:Qiita` | 1 | 0.68s | `tmp/source_fetch_issue22/qiita.json` |
-| RSS | `rss:https://zenn.dev/zenn/feed` | 1 | 0.18s | `tmp/source_fetch_issue22/rss.json` |
-| note | `note:cor_instrument` | 1 | 0.66s | `tmp/source_fetch_issue22/note.json` |
-| Zenn | `zenn:zenn` | 1 | 0.19s | `tmp/source_fetch_issue22/zenn.json` |
+| Source | Selector | Articles | Elapsed | Avg content length | Output |
+|---|---|---:|---:|---:|---|
+| note | `note:cor_instrument` | 20 | 6.99s | not part of this corrective check | `tmp/source_fetch_cloudia_history/note.json` |
+| Zenn | `zenn:cloudia` | 7 | 1.08s | 4,139 chars | `tmp/source_fetch_cloudia_history/zenn.json` |
+| Qiita | `qiita:Cloudia_Cor_Inc` | 12 | 0.28s | 3,274 chars | `tmp/source_fetch_cloudia_history/qiita.json` |
+| Cor RSS | `rss:https://cor-jp.com/rss.xml` | 10 | 0.16s | 69 chars | `tmp/source_fetch_cloudia_history/rss.json` |
+| Cor GitHub Markdown | `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` | 10 | 0.62s | 4,218 chars | `tmp/source_fetch_cloudia_history/github.json` |
-Recent-source check:
+Historical-source check:
-- Qiita returned `Qiita アップデートサマリー - 2026年4月`, which is inside the requested recent two-month window from 2026-05-02.
-- Zenn/RSS returned Zenn official content from the current public feed path.
-- note returned the latest public `cor_instrument` article available through the existing note source path.
+- Zenn `cloudia` returned all 7 public feed items from `?all=1`, with article-page body extraction.
+- Qiita `Cloudia_Cor_Inc` returned 12 public user items through the official user items API, with Markdown `body`.
+- Cor RSS returned 10 public feed items but only short descriptions, confirming that RSS alone is insufficient for company-blog style analysis.
+- Cor GitHub Markdown returned 10 Japanese blog Markdown files with frontmatter and full body content, confirming this is the practical source for company-blog output mode.
## Tests
@@ -65,4 +76,5 @@ Focused source tests cover:
- RSS 2.0 `content:encoded` parsing.
- Atom entry parsing.
- explicit `zenn:`, `qiita:`, and `rss:` selector routing.
-- host-based routing for note.com, zenn.dev, qiita.com, RSS-like URLs, and generic HTML.
+- explicit `github:` selector routing.
+- host-based routing for note.com, zenn.dev, qiita.com, GitHub/blob/raw URLs, RSS-like URLs, and generic HTML.
diff --git a/internal/domain/source/source.go b/internal/domain/source/source.go
index 6989836..c7cad67 100644
--- a/internal/domain/source/source.go
+++ b/internal/domain/source/source.go
@@ -12,11 +12,12 @@ import (
type Kind string
const (
- KindNote Kind = "note"
- KindZenn Kind = "zenn"
- KindQiita Kind = "qiita"
- KindRSS Kind = "rss"
- KindHTML Kind = "html"
+ KindNote Kind = "note"
+ KindZenn Kind = "zenn"
+ KindQiita Kind = "qiita"
+ KindRSS Kind = "rss"
+ KindHTML Kind = "html"
+ KindGitHub Kind = "github"
)
// Ref points to a public author, feed, or article.
@@ -29,7 +30,7 @@ type Ref struct {
// Validate rejects empty or unknown references.
func (r Ref) Validate() error {
switch r.Kind {
- case KindNote, KindZenn, KindQiita:
+ case KindNote, KindZenn, KindQiita, KindGitHub:
if strings.TrimSpace(r.Ref) == "" && strings.TrimSpace(r.URL) == "" {
return fmt.Errorf("%s source requires ref or url", r.Kind)
}
diff --git a/internal/infrastructure/source/github.go b/internal/infrastructure/source/github.go
new file mode 100644
index 0000000..dd7b829
--- /dev/null
+++ b/internal/infrastructure/source/github.go
@@ -0,0 +1,285 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+// GitHubMarkdownFetcher reads Markdown files from public GitHub repositories.
+// It is used for site-backed blogs such as corsweb2024 where the repository is
+// the canonical source for frontmatter and Markdown body.
+type GitHubMarkdownFetcher struct {
+ client *http.Client
+}
+
+// NewGitHubMarkdownFetcher creates a GitHub Contents API fetcher.
+func NewGitHubMarkdownFetcher(client *http.Client) *GitHubMarkdownFetcher {
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ return &GitHubMarkdownFetcher{client: client}
+}
+
+// FetchList fetches Markdown files from a repository directory.
+func (f *GitHubMarkdownFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int) ([]sourcedomain.ArticleSnapshot, error) {
+ if err := ref.Validate(); err != nil {
+ return nil, err
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+ repoRef, err := parseGitHubRef(ref)
+ if err != nil {
+ return nil, err
+ }
+ entries, err := f.listContents(ctx, repoRef)
+ if err != nil {
+ return nil, err
+ }
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, min(limit, len(entries)))
+ for _, entry := range entries {
+ if entry.Type != "file" || !isMarkdownPath(entry.Path) {
+ continue
+ }
+ snapshot, err := f.snapshotFromDownload(ctx, repoRef, entry)
+ if err != nil {
+ continue
+ }
+ snapshots = append(snapshots, snapshot)
+ }
+ sort.SliceStable(snapshots, func(i, j int) bool {
+ left := snapshots[i].PublishedAt
+ right := snapshots[j].PublishedAt
+ if !left.IsZero() || !right.IsZero() {
+ return left.After(right)
+ }
+ return snapshots[i].Title < snapshots[j].Title
+ })
+ if len(snapshots) > limit {
+ snapshots = snapshots[:limit]
+ }
+ return snapshots, nil
+}
+
+// FetchArticle fetches one Markdown file by GitHub URL or github: ref.
+func (f *GitHubMarkdownFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ if err := ref.Validate(); err != nil {
+ return nil, err
+ }
+ repoRef, err := parseGitHubRef(ref)
+ if err != nil {
+ return nil, err
+ }
+ if !isMarkdownPath(repoRef.Path) {
+ return nil, fmt.Errorf("github article path must be markdown: %s", repoRef.Path)
+ }
+ entry := githubContentEntry{
+ Name: path.Base(repoRef.Path),
+ Path: repoRef.Path,
+ Type: "file",
+ DownloadURL: rawGitHubURL(repoRef),
+ HTMLURL: githubBlobURL(repoRef),
+ }
+ snapshot, err := f.snapshotFromDownload(ctx, repoRef, entry)
+ if err != nil {
+ return nil, err
+ }
+ return &snapshot, nil
+}
+
+func (f *GitHubMarkdownFetcher) listContents(ctx context.Context, ref githubRef) ([]githubContentEntry, error) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s",
+ url.PathEscape(ref.Owner),
+ url.PathEscape(ref.Repo),
+ strings.TrimLeft(ref.Path, "/"),
+ url.QueryEscape(ref.Branch),
+ )
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create github contents request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "application/vnd.github+json")
+ response, err := f.client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("fetch github contents: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("fetch github contents: unexpected status %s", response.Status)
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read github contents: %w", err)
+ }
+ var entries []githubContentEntry
+ if err := json.Unmarshal(body, &entries); err == nil {
+ return entries, nil
+ }
+ var entry githubContentEntry
+ if err := json.Unmarshal(body, &entry); err != nil {
+ return nil, fmt.Errorf("decode github contents: %w", err)
+ }
+ return []githubContentEntry{entry}, nil
+}
+
+func (f *GitHubMarkdownFetcher) snapshotFromDownload(ctx context.Context, repoRef githubRef, entry githubContentEntry) (sourcedomain.ArticleSnapshot, error) {
+ downloadURL := strings.TrimSpace(entry.DownloadURL)
+ if downloadURL == "" {
+ downloadURL = rawGitHubURL(githubRef{Owner: repoRef.Owner, Repo: repoRef.Repo, Branch: repoRef.Branch, Path: entry.Path})
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
+ if err != nil {
+ return sourcedomain.ArticleSnapshot{}, fmt.Errorf("create github raw request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "text/markdown, text/plain;q=0.9, */*;q=0.8")
+ response, err := f.client.Do(request)
+ if err != nil {
+ return sourcedomain.ArticleSnapshot{}, fmt.Errorf("fetch github raw: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return sourcedomain.ArticleSnapshot{}, fmt.Errorf("fetch github raw: unexpected status %s", response.Status)
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return sourcedomain.ArticleSnapshot{}, fmt.Errorf("read github raw: %w", err)
+ }
+ content := strings.TrimSpace(string(body))
+ if content == "" {
+ return sourcedomain.ArticleSnapshot{}, fmt.Errorf("github markdown was empty: %s", entry.Path)
+ }
+ metadata, markdown := splitFrontmatter(content)
+ title := firstNonEmpty(metadata["title"], firstMarkdownHeading(markdown), strings.TrimSuffix(path.Base(entry.Path), path.Ext(entry.Path)))
+ publishedAt := parseFeedTime(metadata["pubDate"])
+ htmlURL := firstNonEmpty(entry.HTMLURL, githubBlobURL(githubRef{Owner: repoRef.Owner, Repo: repoRef.Repo, Branch: repoRef.Branch, Path: entry.Path}))
+ return sourcedomain.ArticleSnapshot{
+ ID: entry.Path,
+ Kind: sourcedomain.KindGitHub,
+ URL: htmlURL,
+ Title: normalizeWhitespace(unquoteYAMLScalar(title)),
+ Content: content,
+ PublishedAt: publishedAt,
+ UpdatedAt: publishedAt,
+ FetchedAt: time.Now().UTC(),
+ }, nil
+}
+
+func parseGitHubRef(ref sourcedomain.Ref) (githubRef, error) {
+ if strings.TrimSpace(ref.URL) != "" {
+ return parseGitHubURL(ref.URL)
+ }
+ value := strings.Trim(strings.TrimSpace(ref.Ref), "/")
+ parts := strings.Split(value, "/")
+ if len(parts) < 3 {
+ return githubRef{}, fmt.Errorf("github ref must be owner/repo/path")
+ }
+ branch := "main"
+ repoPathParts := parts[2:]
+ if repo, branchValue, ok := strings.Cut(parts[1], "@"); ok {
+ parts[1] = repo
+ branch = branchValue
+ }
+ return githubRef{Owner: parts[0], Repo: parts[1], Branch: branch, Path: strings.Join(repoPathParts, "/")}, nil
+}
+
+func parseGitHubURL(rawURL string) (githubRef, error) {
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil {
+ return githubRef{}, fmt.Errorf("parse github url: %w", err)
+ }
+ parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ host := strings.ToLower(parsed.Hostname())
+ switch {
+ case host == "github.com" || strings.HasSuffix(host, ".github.com"):
+ if len(parts) < 5 || (parts[2] != "blob" && parts[2] != "tree") {
+ return githubRef{}, fmt.Errorf("github url must be /owner/repo/blob-or-tree/branch/path")
+ }
+ return githubRef{Owner: parts[0], Repo: parts[1], Branch: parts[3], Path: strings.Join(parts[4:], "/")}, nil
+ case host == "raw.githubusercontent.com":
+ if len(parts) < 4 {
+ return githubRef{}, fmt.Errorf("raw github url must be /owner/repo/branch/path")
+ }
+ return githubRef{Owner: parts[0], Repo: parts[1], Branch: parts[2], Path: strings.Join(parts[3:], "/")}, nil
+ default:
+ return githubRef{}, fmt.Errorf("unsupported github host %s", parsed.Hostname())
+ }
+}
+
+func rawGitHubURL(ref githubRef) string {
+ return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", ref.Owner, ref.Repo, ref.Branch, strings.TrimLeft(ref.Path, "/"))
+}
+
+func githubBlobURL(ref githubRef) string {
+ return fmt.Sprintf("https://github.com/%s/%s/blob/%s/%s", ref.Owner, ref.Repo, ref.Branch, strings.TrimLeft(ref.Path, "/"))
+}
+
+func isMarkdownPath(value string) bool {
+ lower := strings.ToLower(value)
+ return strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".mdx")
+}
+
+func splitFrontmatter(content string) (map[string]string, string) {
+ metadata := map[string]string{}
+ normalized := strings.ReplaceAll(strings.ReplaceAll(content, "\r\n", "\n"), "\r", "\n")
+ if !strings.HasPrefix(normalized, "---\n") {
+ return metadata, content
+ }
+ rest := strings.TrimPrefix(normalized, "---\n")
+ end := strings.Index(rest, "\n---")
+ if end < 0 {
+ return metadata, content
+ }
+ frontmatter := rest[:end]
+ body := strings.TrimSpace(rest[end+len("\n---"):])
+ for _, line := range strings.Split(frontmatter, "\n") {
+ key, value, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ metadata[strings.TrimSpace(key)] = strings.TrimSpace(value)
+ }
+ return metadata, body
+}
+
+func unquoteYAMLScalar(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.Trim(value, `"'`)
+ return value
+}
+
+func firstMarkdownHeading(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "# ") {
+ return strings.TrimSpace(strings.TrimPrefix(line, "# "))
+ }
+ }
+ return ""
+}
+
+type githubRef struct {
+ Owner string
+ Repo string
+ Path string
+ Branch string
+}
+
+type githubContentEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Type string `json:"type"`
+ DownloadURL string `json:"download_url"`
+ HTMLURL string `json:"html_url"`
+}
diff --git a/internal/infrastructure/source/router.go b/internal/infrastructure/source/router.go
index 06886f2..172a03a 100644
--- a/internal/infrastructure/source/router.go
+++ b/internal/infrastructure/source/router.go
@@ -15,11 +15,12 @@ import (
// Router dispatches public source refs to concrete fetchers.
type Router struct {
- note *notenote.Fetcher
- zenn *ZennFetcher
- qiita *QiitaFetcher
- rss *RSSFetcher
- html *HTMLFetcher
+ note *notenote.Fetcher
+ zenn *ZennFetcher
+ qiita *QiitaFetcher
+ rss *RSSFetcher
+ html *HTMLFetcher
+ github *GitHubMarkdownFetcher
}
// NewRouter creates a source router with shared HTTP settings.
@@ -33,11 +34,12 @@ func NewRouterWithClient(client *http.Client) *Router {
client = &http.Client{Timeout: 20 * time.Second}
}
return &Router{
- note: notenote.NewFetcherWithClient(client),
- zenn: NewZennFetcher(client),
- qiita: NewQiitaFetcher(client),
- rss: NewRSSFetcher(client),
- html: NewHTMLFetcher(client),
+ note: notenote.NewFetcherWithClient(client),
+ zenn: NewZennFetcher(client),
+ qiita: NewQiitaFetcher(client),
+ rss: NewRSSFetcher(client),
+ html: NewHTMLFetcher(client),
+ github: NewGitHubMarkdownFetcher(client),
}
}
@@ -56,6 +58,8 @@ func (r *Router) FetchList(ctx context.Context, ref sourcedomain.Ref, limit int)
return r.rss.FetchList(ctx, ref, limit)
case sourcedomain.KindHTML:
return r.html.FetchList(ctx, ref, limit)
+ case sourcedomain.KindGitHub:
+ return r.github.FetchList(ctx, ref, limit)
default:
return nil, fmt.Errorf("unsupported source kind %q", ref.Kind)
}
@@ -80,6 +84,8 @@ func (r *Router) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourc
return r.rss.FetchArticle(ctx, ref)
case sourcedomain.KindHTML:
return r.html.FetchArticle(ctx, ref)
+ case sourcedomain.KindGitHub:
+ return r.github.FetchArticle(ctx, ref)
default:
return nil, fmt.Errorf("unsupported source kind %q", ref.Kind)
}
@@ -111,7 +117,8 @@ func (f *AuthorStyleFetcher) FetchArticle(ctx context.Context, articleURL string
}
// FetchUserLatestArticles supports explicit refs such as zenn:cloudia,
-// qiita:Cloudia_Cor_Inc, rss:https://example.com/feed.xml, and legacy note usernames.
+// qiita:Cloudia_Cor_Inc, rss:https://example.com/feed.xml,
+// github:owner/repo/path, and legacy note usernames.
func (f *AuthorStyleFetcher) FetchUserLatestArticles(ctx context.Context, username string, limit int) ([]articledomain.Article, error) {
snapshots, err := f.router.FetchList(ctx, RefFromSelector(username), limit)
if err != nil {
@@ -147,6 +154,8 @@ func RefFromURL(rawURL string) sourcedomain.Ref {
return sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: rawURL}
case host == "qiita.com" || strings.HasSuffix(host, ".qiita.com"):
return sourcedomain.Ref{Kind: sourcedomain.KindQiita, URL: rawURL}
+ case host == "github.com" || strings.HasSuffix(host, ".github.com") || host == "raw.githubusercontent.com":
+ return sourcedomain.Ref{Kind: sourcedomain.KindGitHub, URL: rawURL}
case strings.Contains(strings.ToLower(parsed.Path), "rss") || strings.Contains(strings.ToLower(parsed.Path), "feed") || strings.HasSuffix(strings.ToLower(parsed.Path), ".xml"):
return sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: rawURL}
default:
@@ -181,6 +190,8 @@ func parseKindSelector(selector string) (sourcedomain.Ref, bool) {
return sourcedomain.Ref{Kind: sourcedomain.KindRSS, URL: value}, true
case "html":
return sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: value}, true
+ case "github":
+ return sourcedomain.Ref{Kind: sourcedomain.KindGitHub, Ref: strings.Trim(value, "/")}, true
default:
return sourcedomain.Ref{}, false
}
diff --git a/internal/infrastructure/source/rss_test.go b/internal/infrastructure/source/rss_test.go
index 67e6085..b09fa38 100644
--- a/internal/infrastructure/source/rss_test.go
+++ b/internal/infrastructure/source/rss_test.go
@@ -67,10 +67,21 @@ func TestAuthorStyleFetcherRoutesExplicitSources(t *testing.T) {
t.Fatalf("zenn feed should request all=1, got %s", r.URL.RawQuery)
}
_, _ = w.Write([]byte(`Zenn https://zenn.dev/zenn-user/articles/aZenn本文]]> `))
+ case "/zenn-user/articles/a":
+ _, _ = w.Write([]byte(`Zenn Zenn記事ページ本文
`))
case "/api/v2/users/qiita-user/items":
_, _ = w.Write([]byte(`[{"id":"abc","url":"https://qiita.com/qiita-user/items/abc","title":"Qiita","body":"# Qiita本文","created_at":"2026-05-02T00:00:00+09:00","updated_at":"2026-05-02T00:00:00+09:00"}]`))
case "/feed.xml":
_, _ = w.Write([]byte(`RSS https://example.com/rssRSS本文]]> `))
+ case "/repos/owner/repo/contents/src/content/blog/ja":
+ _, _ = w.Write([]byte(`[{"name":"post.md","path":"src/content/blog/ja/post.md","type":"file","download_url":"https://raw.githubusercontent.com/owner/repo/main/src/content/blog/ja/post.md","html_url":"https://github.com/owner/repo/blob/main/src/content/blog/ja/post.md"}]`))
+ case "/owner/repo/main/src/content/blog/ja/post.md":
+ _, _ = w.Write([]byte(`---
+title: "GitHub記事"
+pubDate: 2026-05-02
+---
+
+GitHub Markdown本文`))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
@@ -79,16 +90,17 @@ func TestAuthorStyleFetcherRoutesExplicitSources(t *testing.T) {
fetcher := NewAuthorStyleFetcherWithClient(mappedClient(server.URL))
cases := map[string]string{
- "zenn:zenn-user": "Zenn本文",
- "qiita:qiita-user": "# Qiita本文",
- "rss:https://x/feed.xml": "RSS本文",
+ "zenn:zenn-user": "Zenn\n\nZenn記事ページ本文",
+ "qiita:qiita-user": "# Qiita本文",
+ "rss:https://x/feed.xml": "RSS本文",
+ "github:owner/repo/src/content/blog/ja": `title: "GitHub記事"`,
}
for selector, wantContent := range cases {
articles, err := fetcher.FetchUserLatestArticles(context.Background(), selector, 3)
if err != nil {
t.Fatalf("%s: fetch latest: %v", selector, err)
}
- if len(articles) != 1 || articles[0].Content != wantContent {
+ if len(articles) != 1 || !strings.Contains(articles[0].Content, wantContent) {
t.Fatalf("%s: unexpected articles: %#v", selector, articles)
}
}
@@ -96,11 +108,12 @@ func TestAuthorStyleFetcherRoutesExplicitSources(t *testing.T) {
func TestRefFromURLRoutesKnownHosts(t *testing.T) {
tests := map[string]sourcedomain.Kind{
- "https://note.com/user/n/n1": sourcedomain.KindNote,
- "https://zenn.dev/user/articles/abc": sourcedomain.KindZenn,
- "https://qiita.com/user/items/abc": sourcedomain.KindQiita,
- "https://example.com/rss.xml": sourcedomain.KindRSS,
- "https://example.com/blog/post": sourcedomain.KindHTML,
+ "https://note.com/user/n/n1": sourcedomain.KindNote,
+ "https://zenn.dev/user/articles/abc": sourcedomain.KindZenn,
+ "https://qiita.com/user/items/abc": sourcedomain.KindQiita,
+ "https://example.com/rss.xml": sourcedomain.KindRSS,
+ "https://github.com/o/r/blob/main/a.md": sourcedomain.KindGitHub,
+ "https://example.com/blog/post": sourcedomain.KindHTML,
}
for rawURL, want := range tests {
if got := RefFromURL(rawURL).Kind; got != want {
diff --git a/internal/infrastructure/source/text.go b/internal/infrastructure/source/text.go
index bb0b1bd..c0b111e 100644
--- a/internal/infrastructure/source/text.go
+++ b/internal/infrastructure/source/text.go
@@ -2,6 +2,7 @@ package source
import (
"strings"
+ "time"
"github.com/PuerkitoBio/goquery"
)
@@ -59,3 +60,12 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
+
+func firstNonZeroTime(values ...time.Time) time.Time {
+ for _, value := range values {
+ if !value.IsZero() {
+ return value
+ }
+ }
+ return time.Time{}
+}
diff --git a/internal/infrastructure/source/zenn.go b/internal/infrastructure/source/zenn.go
index 5b001ca..4535e07 100644
--- a/internal/infrastructure/source/zenn.go
+++ b/internal/infrastructure/source/zenn.go
@@ -2,11 +2,14 @@ package source
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
+ "time"
+ "github.com/PuerkitoBio/goquery"
sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
)
@@ -42,18 +45,67 @@ func (f *ZennFetcher) FetchList(ctx context.Context, ref sourcedomain.Ref, limit
return nil, fmt.Errorf("zenn user ref is required")
}
feedURL := fmt.Sprintf("https://zenn.dev/%s/feed?all=1", url.PathEscape(user))
- snapshots, err := f.rss.FetchList(ctx, sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: user, URL: feedURL}, limit)
+ feedSnapshots, err := f.rss.FetchList(ctx, sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: user, URL: feedURL}, limit)
if err != nil {
return nil, err
}
- for i := range snapshots {
- snapshots[i].Kind = sourcedomain.KindZenn
+ snapshots := make([]sourcedomain.ArticleSnapshot, 0, len(feedSnapshots))
+ for _, feedSnapshot := range feedSnapshots {
+ feedSnapshot.Kind = sourcedomain.KindZenn
+ article, err := f.FetchArticle(ctx, sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: feedSnapshot.URL})
+ if err != nil || article == nil || strings.TrimSpace(article.Content) == "" {
+ snapshots = append(snapshots, feedSnapshot)
+ continue
+ }
+ article.ID = firstNonEmpty(feedSnapshot.ID, article.ID)
+ article.Title = firstNonEmpty(feedSnapshot.Title, article.Title)
+ article.PublishedAt = firstNonZeroTime(feedSnapshot.PublishedAt, article.PublishedAt)
+ article.UpdatedAt = firstNonZeroTime(feedSnapshot.UpdatedAt, article.UpdatedAt)
+ article.FetchedAt = firstNonZeroTime(article.FetchedAt, feedSnapshot.FetchedAt)
+ snapshots = append(snapshots, *article)
}
return snapshots, nil
}
// FetchArticle fetches one Zenn article page.
func (f *ZennFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*sourcedomain.ArticleSnapshot, error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(ref.URL), nil)
+ if err != nil {
+ return nil, fmt.Errorf("create zenn article request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ response, err := f.html.client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("fetch zenn article: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("fetch zenn article: unexpected status %s", response.Status)
+ }
+ doc, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("parse zenn article html: %w", err)
+ }
+ title := firstNonEmpty(
+ selectionAttr(doc, `meta[property="og:title"]`, "content"),
+ selectionText(doc, "h1"),
+ selectionText(doc, "title"),
+ )
+ nextData := zennNextDataFromDocument(doc)
+ if strings.TrimSpace(nextData.Props.PageProps.Article.BodyHTML) != "" {
+ content := htmlToParagraphText(nextData.Props.PageProps.Article.BodyHTML)
+ if content != "" {
+ return &sourcedomain.ArticleSnapshot{
+ ID: ref.URL,
+ Kind: sourcedomain.KindZenn,
+ URL: ref.URL,
+ Title: normalizeWhitespace(firstNonEmpty(nextData.Props.PageProps.Article.Title, title)),
+ Content: content,
+ FetchedAt: time.Now().UTC(),
+ }, nil
+ }
+ }
article, err := f.html.FetchArticle(ctx, sourcedomain.Ref{Kind: sourcedomain.KindHTML, URL: ref.URL})
if err != nil {
return nil, err
@@ -61,3 +113,24 @@ func (f *ZennFetcher) FetchArticle(ctx context.Context, ref sourcedomain.Ref) (*
article.Kind = sourcedomain.KindZenn
return article, nil
}
+
+func zennNextDataFromDocument(doc *goquery.Document) zennNextData {
+ var data zennNextData
+ raw := strings.TrimSpace(doc.Find("script#__NEXT_DATA__").First().Text())
+ if raw == "" {
+ return data
+ }
+ _ = json.Unmarshal([]byte(raw), &data)
+ return data
+}
+
+type zennNextData struct {
+ Props struct {
+ PageProps struct {
+ Article struct {
+ Title string `json:"title"`
+ BodyHTML string `json:"bodyHtml"`
+ } `json:"article"`
+ } `json:"pageProps"`
+ } `json:"props"`
+}
From ac87d56057282cee5620f00ce50ce7ab330b6133 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 00:14:42 +0900
Subject: [PATCH 16/33] Implement persona format question templates
---
cmd/scenario/media_matrix/main.go | 645 ++++++++++++++++++
cmd/server/main.go | 1 +
...02-multi-persona-multi-format-extension.md | 2 +
.../multi-persona-multi-format.md | 2 +
...matrix-integrated-evaluation-2026-05-03.md | 136 ++++
internal/application/brief/service.go | 14 +-
internal/application/brief/service_test.go | 60 ++
internal/domain/brief/session_test.go | 67 ++
internal/domain/brief/types.go | 105 +++
internal/handlers/workflow.go | 58 +-
internal/handlers/workflow_handler_test.go | 27 +
internal/handlers/workflow_mode_test.go | 40 ++
static/css/style.css | 28 +
static/js/script.js | 275 +++++---
14 files changed, 1365 insertions(+), 95 deletions(-)
create mode 100644 cmd/scenario/media_matrix/main.go
create mode 100644 docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
diff --git a/cmd/scenario/media_matrix/main.go b/cmd/scenario/media_matrix/main.go
new file mode 100644
index 0000000..22ce6d8
--- /dev/null
+++ b/cmd/scenario/media_matrix/main.go
@@ -0,0 +1,645 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ 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"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
+)
+
+const defaultOutputDir = "tmp/media_matrix"
+
+var expectedSourceSelectors = []string{
+ "note:cor_instrument",
+ "zenn:cloudia",
+ "qiita:Cloudia_Cor_Inc",
+ "rss:https://cor-jp.com/rss.xml",
+ "github:Cor-Incorporated/corsweb2024/src/content/blog/ja",
+}
+
+type matrixCase struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ Theme string `json:"theme"`
+ OpeningEpisode string `json:"opening_episode"`
+ Reader string `json:"reader"`
+ ExpectedReaderAction string `json:"expected_reader_action"`
+ MustInclude string `json:"must_include"`
+ PersonalContext string `json:"personal_context"`
+ Exclusions string `json:"exclusions"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ ToneStance string `json:"tone_stance"`
+ SourceSelectors []string `json:"source_selectors"`
+ PromptMustContain []string `json:"prompt_must_contain"`
+}
+
+type matrixOutput struct {
+ GeneratedBy string `json:"generated_by"`
+ OfflineOnly bool `json:"offline_only"`
+ ExpectedSourceSelectors []sourceSelectorResult `json:"expected_source_selectors"`
+ FixedQuestionTemplate []questionTemplateEntry `json:"fixed_question_template"`
+ ComposedQuestionTemplates []questionTemplateSet `json:"composed_question_templates"`
+ Cases []caseResult `json:"cases"`
+ ComparisonMetricsTemplate []string `json:"comparison_metrics_template"`
+}
+
+type sourceSelectorResult struct {
+ Selector string `json:"selector"`
+ Found bool `json:"found"`
+ Persona string `json:"persona,omitempty"`
+ Kind string `json:"kind,omitempty"`
+}
+
+type questionTemplateEntry struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Required bool `json:"required"`
+ TargetField string `json:"target_field"`
+}
+
+type questionTemplateSet struct {
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Questions []questionTemplateEntry `json:"questions"`
+}
+
+type caseResult struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ PersonaDisplayName string `json:"persona_display_name"`
+ OutputFormatID string `json:"output_format_id"`
+ OutputFormatName string `json:"output_format_name"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ Theme string `json:"theme"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ SourceSelectors []string `json:"source_selectors"`
+ BriefPath string `json:"brief_path"`
+ PromptPath string `json:"prompt_path"`
+ PromptChecks []promptCheckResult `json:"prompt_checks"`
+ QuestionIDs []string `json:"question_ids"`
+ PlannedLLMCommand string `json:"planned_llm_command"`
+ ExpectedMetrics []string `json:"expected_metrics"`
+}
+
+type promptCheckResult struct {
+ Contains string `json:"contains"`
+ Passed bool `json:"passed"`
+}
+
+func main() {
+ outputDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)
+ briefDir := filepath.Join(outputDir, "briefs")
+ promptDir := filepath.Join(outputDir, "prompts")
+ for _, dir := range []string{outputDir, briefDir, promptDir} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ fatalf("create output dir %s: %v", dir, err)
+ }
+ }
+
+ personas := personadomain.DefaultRegistry()
+ formats := outputformat.DefaultRegistry()
+ sourceResults := verifyExpectedSources(personas.List())
+ questionTemplate := fixedQuestionTemplate()
+ composedTemplates := composedQuestionTemplates()
+ guide := scenarioGuide()
+
+ results := make([]caseResult, 0, len(plannedCases()))
+ for _, item := range plannedCases() {
+ persona, ok := personas.Get(item.PersonaID)
+ if !ok {
+ fatalf("%s references unknown persona %s", item.ID, item.PersonaID)
+ }
+ format, ok := formats.Get(item.OutputFormatID)
+ if !ok {
+ fatalf("%s references unknown output format %s", item.ID, item.OutputFormatID)
+ }
+ verifyCaseSources(item, sourceResults)
+ questions := briefdomain.ComposeFixedQuestions(item.PersonaID, item.OutputFormatID)
+ questionIDs := questionIDsFromQuestions(questions)
+
+ brief := buildBriefFromSession(item)
+ if brief.PersonaID != persona.ID || brief.OutputFormatID != format.ID {
+ fatalf("%s assembled mismatched brief persona/format", item.ID)
+ }
+ prompt := draftapp.BuildPromptForMode(guide, brief, persona, format)
+ checks := verifyPrompt(item, persona, format, prompt)
+
+ briefPath := filepath.Join(briefDir, item.ID+".json")
+ promptPath := filepath.Join(promptDir, item.ID+".prompt.md")
+ writeJSON(briefPath, brief)
+ writeFile(promptPath, prompt)
+
+ results = append(results, caseResult{
+ ID: item.ID,
+ PersonaID: persona.ID,
+ PersonaDisplayName: persona.DisplayName,
+ OutputFormatID: format.ID,
+ OutputFormatName: format.DisplayName,
+ Medium: item.Medium,
+ Style: item.Style,
+ Theme: item.Theme,
+ TargetLengthStructure: item.TargetLengthStructure,
+ SourceSelectors: append([]string(nil), item.SourceSelectors...),
+ BriefPath: briefPath,
+ PromptPath: promptPath,
+ PromptChecks: checks,
+ QuestionIDs: append([]string(nil), questionIDs...),
+ PlannedLLMCommand: plannedLLMCommand(outputDir, item.ID, briefPath),
+ ExpectedMetrics: []string{
+ "elapsed_seconds",
+ "score",
+ "passed",
+ "verification_performed",
+ "verification_passed",
+ "runes",
+ },
+ })
+ }
+
+ matrix := matrixOutput{
+ GeneratedBy: "cmd/scenario/media_matrix",
+ OfflineOnly: true,
+ ExpectedSourceSelectors: sourceResults,
+ FixedQuestionTemplate: questionTemplate,
+ ComposedQuestionTemplates: composedTemplates,
+ Cases: results,
+ ComparisonMetricsTemplate: []string{
+ "case_id",
+ "phase",
+ "medium",
+ "style",
+ "target_length_structure",
+ "elapsed_seconds",
+ "score",
+ "verification_passed",
+ "output_path",
+ },
+ }
+ writeJSON(filepath.Join(outputDir, "matrix.json"), matrix)
+ writeFile(filepath.Join(outputDir, "cases.md"), casesMarkdown(results))
+
+ fmt.Printf("media matrix scenario completed\n")
+ fmt.Printf("offline_only=%v\n", matrix.OfflineOnly)
+ fmt.Printf("source_selectors=%d\n", len(matrix.ExpectedSourceSelectors))
+ fmt.Printf("question_template_ids=%d\n", len(matrix.FixedQuestionTemplate))
+ fmt.Printf("composed_templates=%d\n", len(matrix.ComposedQuestionTemplates))
+ fmt.Printf("cases=%d\n", len(matrix.Cases))
+ fmt.Printf("matrix=%s\n", filepath.Join(outputDir, "matrix.json"))
+ fmt.Printf("cases_markdown=%s\n", filepath.Join(outputDir, "cases.md"))
+}
+
+func verifyExpectedSources(personas []personadomain.Persona) []sourceSelectorResult {
+ available := map[string]sourceSelectorResult{}
+ for _, persona := range personas {
+ for _, source := range persona.Sources {
+ selector := sourceSelector(source)
+ if selector == "" {
+ continue
+ }
+ available[selector] = sourceSelectorResult{
+ Selector: selector,
+ Found: true,
+ Persona: persona.ID,
+ Kind: source.Kind,
+ }
+ }
+ }
+
+ results := make([]sourceSelectorResult, 0, len(expectedSourceSelectors))
+ for _, selector := range expectedSourceSelectors {
+ result, ok := available[selector]
+ if !ok {
+ fatalf("expected source selector %s was not found in seeded personas", selector)
+ }
+ results = append(results, result)
+ }
+ return results
+}
+
+func sourceSelector(source personadomain.AuthorSource) string {
+ kind := strings.TrimSpace(source.Kind)
+ ref := strings.TrimSpace(source.Ref)
+ url := strings.TrimSpace(source.URL)
+ switch kind {
+ case "note", "zenn", "qiita":
+ if ref == "" {
+ return ""
+ }
+ return kind + ":" + ref
+ case "rss":
+ if url == "" {
+ return ""
+ }
+ return "rss:" + strings.TrimSuffix(url, "/")
+ case "github":
+ return githubSelector(url)
+ default:
+ return ""
+ }
+}
+
+func githubSelector(url string) string {
+ const marker = "github.com/"
+ index := strings.Index(url, marker)
+ if index < 0 {
+ return ""
+ }
+ path := strings.TrimPrefix(url[index+len(marker):], "/")
+ path = strings.TrimSuffix(path, "/")
+ path = strings.Replace(path, "/tree/main/", "/", 1)
+ path = strings.Replace(path, "/tree/master/", "/", 1)
+ if path == "" {
+ return ""
+ }
+ return "github:" + path
+}
+
+func fixedQuestionTemplate() []questionTemplateEntry {
+ questions := briefdomain.FixedQuestions()
+ entries := make([]questionTemplateEntry, 0, len(questions))
+ requiredIDs := map[string]bool{
+ briefdomain.QuestionIDTheme: true,
+ briefdomain.QuestionIDReader: true,
+ briefdomain.QuestionIDExpectedReaderAction: true,
+ briefdomain.QuestionIDMustInclude: true,
+ briefdomain.QuestionIDPersonalContext: true,
+ briefdomain.QuestionIDTargetLengthStructure: true,
+ briefdomain.QuestionIDToneStance: true,
+ }
+ seen := map[string]bool{}
+ for _, question := range questions {
+ seen[question.ID] = true
+ entries = append(entries, questionTemplateEntry{
+ ID: question.ID,
+ Text: question.Text,
+ Required: question.Required,
+ TargetField: question.TargetField,
+ })
+ }
+ for id := range requiredIDs {
+ if !seen[id] {
+ fatalf("fixed question template missing %s", id)
+ }
+ }
+ return entries
+}
+
+func questionIDsFromTemplate(template []questionTemplateEntry) []string {
+ ids := make([]string, 0, len(template))
+ for _, question := range template {
+ ids = append(ids, question.ID)
+ }
+ return ids
+}
+
+func questionIDsFromQuestions(questions []briefdomain.ArticleQuestion) []string {
+ ids := make([]string, 0, len(questions))
+ for _, question := range questions {
+ ids = append(ids, question.ID)
+ }
+ return ids
+}
+
+func composedQuestionTemplates() []questionTemplateSet {
+ personas := []string{personadomain.IDTerisuke, personadomain.IDCloudia}
+ formats := []string{
+ outputformat.IDNoteArticle,
+ outputformat.IDMarkdownBlog,
+ outputformat.IDZennArticle,
+ outputformat.IDQiitaArticle,
+ outputformat.IDHomepageSection,
+ }
+ result := make([]questionTemplateSet, 0, len(personas)*len(formats))
+ for _, personaID := range personas {
+ for _, formatID := range formats {
+ questions := briefdomain.ComposeFixedQuestions(personaID, formatID)
+ if personaID == personadomain.IDCloudia && formatID == outputformat.IDZennArticle {
+ requireQuestion(questions, briefdomain.QuestionIDTargetStack)
+ requireQuestion(questions, briefdomain.QuestionIDCloudiaViewpoint)
+ }
+ result = append(result, questionTemplateSet{
+ PersonaID: personaID,
+ OutputFormatID: formatID,
+ Questions: questionTemplateEntries(questions),
+ })
+ }
+ }
+ return result
+}
+
+func questionTemplateEntries(questions []briefdomain.ArticleQuestion) []questionTemplateEntry {
+ entries := make([]questionTemplateEntry, 0, len(questions))
+ for _, question := range questions {
+ entries = append(entries, questionTemplateEntry{
+ ID: question.ID,
+ Text: question.Text,
+ Required: question.Required,
+ TargetField: question.TargetField,
+ })
+ }
+ return entries
+}
+
+func requireQuestion(questions []briefdomain.ArticleQuestion, id string) {
+ for _, question := range questions {
+ if question.ID == id {
+ return
+ }
+ }
+ fatalf("composed question template missing %s", id)
+}
+
+func verifyCaseSources(item matrixCase, sources []sourceSelectorResult) {
+ available := map[string]bool{}
+ for _, source := range sources {
+ available[source.Selector] = source.Found
+ }
+ for _, selector := range item.SourceSelectors {
+ if !available[selector] {
+ fatalf("%s references unavailable source selector %s", item.ID, selector)
+ }
+ }
+}
+
+func buildBriefFromSession(item matrixCase) briefdomain.ArticleBrief {
+ session, err := briefdomain.NewArticleBriefSessionWithOptions(item.ID, "profile_media_matrix", item.PersonaID, item.OutputFormatID, "", briefdomain.ComposeFixedQuestions(item.PersonaID, item.OutputFormatID))
+ if err != nil {
+ fatalf("%s create brief session: %v", item.ID, err)
+ }
+ for _, question := range session.Questions {
+ if _, err := session.RecordAnswer(answerForQuestion(item, question.ID)); err != nil {
+ fatalf("%s answer %s: %v", item.ID, question.ID, err)
+ }
+ }
+ session.MarkDeepDiveSkipped()
+ brief, err := session.Complete()
+ if err != nil {
+ fatalf("%s complete brief session: %v", item.ID, err)
+ }
+ return brief
+}
+
+func answerForQuestion(item matrixCase, questionID string) string {
+ switch questionID {
+ case briefdomain.QuestionIDTheme:
+ return item.Theme
+ case briefdomain.QuestionIDOpeningEpisode:
+ return item.OpeningEpisode
+ case briefdomain.QuestionIDReader:
+ return item.Reader
+ case briefdomain.QuestionIDExpectedReaderAction:
+ return item.ExpectedReaderAction
+ case briefdomain.QuestionIDMustInclude:
+ return item.MustInclude
+ case briefdomain.QuestionIDPersonalContext:
+ return item.PersonalContext
+ case briefdomain.QuestionIDExclusions:
+ return item.Exclusions
+ case briefdomain.QuestionIDTargetLengthStructure:
+ return item.TargetLengthStructure
+ case briefdomain.QuestionIDToneStance:
+ return item.ToneStance
+ case briefdomain.QuestionIDStoryArc:
+ return "導入の違和感から、実践で見えた発見へ進み、読者が次に試す一歩で締める。"
+ case briefdomain.QuestionIDTargetStack:
+ return "Go 1.26、OpenAI互換API、Ollama/Evo X2、Markdown validator、ローカルシナリオCLI。"
+ case briefdomain.QuestionIDTechnicalProof:
+ return "実行コマンド、JSON出力、本文長、style score、verification結果を比較表に残す。"
+ case briefdomain.QuestionIDHomepageCTA:
+ return "問い合わせまたは技術相談への導線を置き、検証可能な発信基盤を短く伝える。"
+ case briefdomain.QuestionIDHomepageTrust:
+ return "実装済みの媒体別fetcher、format validator、シナリオ評価を根拠として示す。"
+ case briefdomain.QuestionIDCloudiaViewpoint:
+ return "クラウディア視点では、つまずきポイントを明るく拾い、初心者が一緒に試せる楽しさを入れる。"
+ default:
+ return ""
+ }
+}
+
+func verifyPrompt(item matrixCase, persona personadomain.Persona, format outputformat.OutputFormat, prompt string) []promptCheckResult {
+ mustContain := []string{
+ persona.DisplayName,
+ format.DisplayName,
+ item.Theme,
+ item.TargetLengthStructure,
+ "## 媒体別Markdownガイド",
+ }
+ mustContain = append(mustContain, item.PromptMustContain...)
+ results := make([]promptCheckResult, 0, len(mustContain))
+ for _, expected := range mustContain {
+ passed := strings.Contains(prompt, expected)
+ if !passed {
+ fatalf("%s prompt missing %q", item.ID, expected)
+ }
+ results = append(results, promptCheckResult{Contains: expected, Passed: passed})
+ }
+ return results
+}
+
+func plannedLLMCommand(outputDir, caseID, briefPath string) string {
+ return fmt.Sprintf("RUN_LOCAL_LLM_SCENARIO=1 ARTICLE_BRIEF_PATH=%s SCENARIO_OUTPUT_DIR=%s go run ./cmd/scenario/draft_generation", briefPath, filepath.Join(outputDir, "live", caseID))
+}
+
+func scenarioGuide() authordomain.WritingStyleGuide {
+ return authordomain.WritingStyleGuide{
+ ID: "guide_media_matrix",
+ ProfileID: "profile_media_matrix",
+ Markdown: "実体験、検証、読者への次の行動を媒体ごとに配分する。媒体が技術寄りなら手順と根拠を厚くし、noteやホームページでは読者の判断につながる文脈を厚くする。",
+ PreferredFirstPerson: "僕",
+ RecurringThemes: []string{"AI", "検証", "発信", "実装"},
+ ParagraphRhythm: "媒体ごとの読み方に合わせて段落密度を変える。",
+ SentenceRhythm: "結論、根拠、次の行動が追える明快な文にする。",
+ HeadingGuidance: "読者が期待する粒度で具体的な見出しにする。",
+ QuoteGuidance: "引用は主張の転換点に限定する。",
+ OpeningPatterns: []string{"具体的な違和感から始める", "検証結果から始める", "読者の作業場面から始める"},
+ ConclusionPatterns: []string{"次の行動で締める", "検証の残課題で締める"},
+ }
+}
+
+func plannedCases() []matrixCase {
+ return []matrixCase{
+ {
+ ID: "terisuke_note_essay",
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ Medium: "note",
+ Style: "reflective essay",
+ Theme: "AI時代に手触りある創作を残す理由",
+ OpeningEpisode: "ローカルLLMで下書きを速く作れた一方で、自分の違和感が抜けた原稿を読み返した場面",
+ Reader: "AIで発信を増やしたいが、自分の言葉が薄まることを気にしている個人クリエイター",
+ ExpectedReaderAction: "生成速度だけでなく、違和感や体験を先にメモしてからAIへ渡す",
+ MustInclude: "創作の手触り、AIを使う前の素材メモ、下書き後の読み返し、読者への提案",
+ PersonalContext: "音楽家、エンジニア、起業家として、便利さと表現の固有性の間で判断してきた経験",
+ Exclusions: "AI万能論、根拠のない効率化断言、クラウディア口調",
+ TargetLengthStructure: "3000字前後。導入、違和感、背景、実践、読者への提案、結論",
+ ToneStance: "内省を中心に、体験から技術との付き合い方へ接続する",
+ SourceSelectors: []string{"note:cor_instrument"},
+ PromptMustContain: []string{"note.com", "frontmatter、HTML"},
+ },
+ {
+ ID: "cor_blog_technical_report",
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDMarkdownBlog,
+ Medium: "cor-jp.com company blog",
+ Style: "technical report",
+ Theme: "ソース別スタイル分析を記事生成に接続する実装報告",
+ OpeningEpisode: "note、Zenn、Qiita、RSS、GitHub Markdownを同じ検証表で見たときに本文密度の差が出た場面",
+ Reader: "Cor.incの開発チームと、記事生成パイプラインの実装判断を知りたい技術読者",
+ ExpectedReaderAction: "媒体ごとの入力ソースと検証指標を分けて、次の実装タスクを切れるようにする",
+ MustInclude: "selector一覧、RSSとGitHub Markdownの役割差、format validator、runtime/score/verificationの比較観点",
+ PersonalContext: "自社発信を継続可能な仕組みにするため、実装と編集判断を同じ場で扱っている背景",
+ Exclusions: "未計測の性能比較、ライブLLM結果の捏造、読者が再現できない手順",
+ TargetLengthStructure: "2200-2800字。背景、実装、検証結果、リスク、次の実装",
+ ToneStance: "会社ブログとして断定口調で、実装判断と検証可能性を明確にする",
+ SourceSelectors: []string{
+ "rss:https://cor-jp.com/rss.xml",
+ "github:Cor-Incorporated/corsweb2024/src/content/blog/ja",
+ },
+ PromptMustContain: []string{"corsweb2024", "category は ai / engineering / founder / lab", "実装判断"},
+ },
+ {
+ ID: "cor_blog_vision_sharing",
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDMarkdownBlog,
+ Medium: "cor-jp.com company blog",
+ Style: "vision sharing",
+ Theme: "生成AI時代のCor.inc発信基盤をどう育てるか",
+ OpeningEpisode: "複数媒体の出力形式を揃えたことで、会社として何を蓄積すべきかが見えた場面",
+ Reader: "Cor.incのメンバーと、会社の発信基盤に関心がある採用候補者",
+ ExpectedReaderAction: "発信を個人技にせず、検証と再利用ができる社内資産として扱う",
+ MustInclude: "会社ブログの役割、技術知見とビジョン共有の両立、媒体別ガイド、今後の運用方針",
+ PersonalContext: "創業者として、プロダクト開発と発信を同じ学習ループに入れたい意図",
+ Exclusions: "抽象的なスローガンだけの記事、採用広報だけに寄った表現",
+ TargetLengthStructure: "1600-2200字。課題、方針、運用、期待する行動",
+ ToneStance: "社員へのビジョン共有として、落ち着いた断定と具体的な運用案を混ぜる",
+ SourceSelectors: []string{
+ "rss:https://cor-jp.com/rss.xml",
+ "github:Cor-Incorporated/corsweb2024/src/content/blog/ja",
+ },
+ PromptMustContain: []string{"社員へのビジョン共有", "lang は必ず \"ja\""},
+ },
+ {
+ ID: "cloudia_zenn_tutorial",
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ Medium: "Zenn",
+ Style: "tutorial",
+ Theme: "Goで媒体別プロンプトを検証する小さなCLIを作る",
+ OpeningEpisode: "記事の出し先を変えたらfrontmatterや独自記法が混ざってエラーになった場面",
+ Reader: "Goで記事生成ツールを作っていて、Zenn向け出力を安定させたい開発者",
+ ExpectedReaderAction: "Zenn用のfrontmatter、topics、messageブロックを分けて検証する",
+ MustInclude: "前提、実装手順、コード例、Zenn独自記法、つまずきポイント",
+ PersonalContext: "クラウディアとして、初心者にも楽しく手順を追える技術解説にする",
+ Exclusions: "Qiitaの:::note、HTML details、重い経営エッセイ調",
+ TargetLengthStructure: "1800-2400字。前提、実装、コード、確認、まとめ",
+ ToneStance: "明るいチュートリアル。博多弁を少し混ぜ、コードと手順を主役にする",
+ SourceSelectors: []string{"zenn:cloudia"},
+ PromptMustContain: []string{":::message", "topics は5個以内", "クラウディア"},
+ },
+ {
+ ID: "cloudia_qiita_how_to",
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDQiitaArticle,
+ Medium: "Qiita",
+ Style: "practical how-to",
+ Theme: "Qiita向け記事でdiffコードと注意書きを正しく出す",
+ OpeningEpisode: "Zenn用の:::messageをQiita原稿に混ぜてしまい、レビューで修正が必要になった場面",
+ Reader: "Qiitaに実装メモを投稿するエンジニア",
+ ExpectedReaderAction: "Qiita形式のfrontmatter、diff_language、:::noteを使って再現手順を書く",
+ MustInclude: "環境、手順、diff_go例、:::note warn、確認結果、参考リンク",
+ PersonalContext: "クラウディアとして、試してすぐ動く実用手順に寄せる",
+ Exclusions: "Zennの:::details、note風の長い内省、未検証のベストプラクティス断言",
+ TargetLengthStructure: "1400-2000字。環境、手順、コード差分、結果、補足",
+ ToneStance: "実用重視の明るいハウツー。手順を短く区切る",
+ SourceSelectors: []string{"qiita:Cloudia_Cor_Inc"},
+ PromptMustContain: []string{":::note info", "diff_ruby", "Qiita"},
+ },
+ {
+ ID: "cor_homepage_section",
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDHomepageSection,
+ Medium: "homepage",
+ Style: "concise product section",
+ Theme: "媒体別の文章生成を事業サイトで短く伝える",
+ OpeningEpisode: "記事生成の検証成果を、トップページの1セクションに圧縮する必要が出た場面",
+ Reader: "Cor.incのサイト訪問者、協業候補、採用候補者",
+ ExpectedReaderAction: "記事生成基盤が実装と発信の両方を支えることを理解し、問い合わせに進む",
+ MustInclude: "媒体別最適化、検証可能な生成、会社サイト向けのCTA",
+ PersonalContext: "Cor.incとして、AI実装と発信支援を同じ品質基準で扱う姿勢",
+ Exclusions: "Markdown、frontmatter、コードブロック、長い説明",
+ TargetLengthStructure: "400-700字相当。h2、短い説明、CTA",
+ ToneStance: "落ち着いた会社サイト文体。短く具体的に価値を伝える",
+ SourceSelectors: []string{
+ "rss:https://cor-jp.com/rss.xml",
+ "github:Cor-Incorporated/corsweb2024/src/content/blog/ja",
+ },
+ PromptMustContain: []string{"", "MarkdownではなくHTML", "CTA"},
+ },
+ }
+}
+
+func casesMarkdown(cases []caseResult) string {
+ var builder strings.Builder
+ builder.WriteString("# Media matrix scenario\n\n")
+ builder.WriteString("Offline planned LLM run matrix. Live source and LLM commands are documented separately and are not run by this scenario.\n\n")
+ builder.WriteString("| Case | Persona | Format | Medium | Style | Target length | Sources |\n")
+ builder.WriteString("|---|---|---|---|---|---|---|\n")
+ for _, item := range cases {
+ builder.WriteString(fmt.Sprintf("| `%s` | `%s` | `%s` | %s | %s | %s | `%s` |\n",
+ item.ID,
+ item.PersonaID,
+ item.OutputFormatID,
+ escapeTable(item.Medium),
+ escapeTable(item.Style),
+ escapeTable(item.TargetLengthStructure),
+ strings.Join(item.SourceSelectors, "`, `"),
+ ))
+ }
+ builder.WriteString("\n## Planned live LLM commands\n\n")
+ for _, item := range cases {
+ builder.WriteString("### " + item.ID + "\n\n")
+ builder.WriteString("```sh\n" + item.PlannedLLMCommand + "\n```\n\n")
+ }
+ return builder.String()
+}
+
+func escapeTable(value string) string {
+ return strings.ReplaceAll(value, "|", "\\|")
+}
+
+func writeJSON(path string, value any) {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatalf("encode %s: %v", path, err)
+ }
+ writeFile(path, string(encoded)+"\n")
+}
+
+func writeFile(path, content string) {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ fatalf("write %s: %v", path, err)
+ }
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 9f0f4ae..70329c0 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -37,6 +37,7 @@ func main() {
r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/analyze", handlers.AnalyzeAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/{id}", handlers.GetAuthorStyleHandler).Methods("GET")
+ r.HandleFunc("/api/brief-sessions/templates", handlers.GetBriefSessionTemplateHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions", handlers.CreateBriefSessionHandler).Methods("POST")
r.HandleFunc("/api/brief-sessions/{id}", handlers.GetBriefSessionHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST")
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 4c2d078..243e7a1 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -157,6 +157,7 @@ Additions:
- `GET /api/personas` / `POST /api/personas` / `PATCH /api/personas/{id}` — persona CRUD.
- `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.
- `GET /api/sessions/{id}/transcript` — chat-style transcript including parent links.
- `POST /api/sessions/{id}/answers/{answer_id}/edit` — fork-on-edit for past answers.
@@ -209,6 +210,7 @@ Current implementation status as of 2026-05-02:
- Phase A4 is implemented and merged: follow-up prompts include parent question, parent answer, and active style guide context; rule-based fallback questions use the same quoted parent-answer prefix; the transcript labels the parent answer as the deep-dive rationale ([#20](https://github.com/terisuke/note_maker/issues/20)). Validation is recorded in [Issue 20 deep-dive rationale validation](../validation/issue-20-deep-dive-rationale-2026-05-02.md).
- Phase A3 is implemented in code: the generated Markdown textarea is editable, preview rendering live-syncs through `marked`, both preview and Markdown tabs can copy content, and `POST /api/drafts/{id}/regenerate-section` rewrites exactly one `## ` subtree while preserving the rest of the draft byte-for-byte ([#19](https://github.com/terisuke/note_maker/issues/19)). Validation is recorded in [Issue 19 section regeneration validation](../validation/issue-19-section-regeneration-2026-05-02.md).
- Phase B3/B4 are implemented for the in-repo registry surface: all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Deterministic scenario validation is recorded in [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). Live source-derived guide rebuilding for Zenn/Qiita/RSS remains part of source acquisition work in [#22](https://github.com/terisuke/note_maker/issues/22).
+- 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)).
Near-term execution order:
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 9e6240d..c5d4749 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -250,6 +250,8 @@ Acceptance:
- Starting a `terisuke × note_article` session is byte-identical to the current question set.
- Custom questions added via the existing config UI are appended after the composed list.
+Implementation note as of 2026-05-03: #25 is implemented with `brief.ComposeFixedQuestions(persona_id, output_format_id)` and `GET /api/brief-sessions/templates`. The frontend now displays server templates as read-only rows and sends only custom additions on session start. `cmd/scenario/media_matrix` validates all 2 personas x 5 formats templates and creates a six-case cross-media evaluation matrix for follow-on live LLM runs.
+
## Phase C — Memory & history
### C1 — SQLite store (extends Issue [#14](https://github.com/terisuke/note_maker/issues/14))
diff --git a/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
new file mode 100644
index 0000000..6cdd168
--- /dev/null
+++ b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
@@ -0,0 +1,136 @@
+# Media matrix integrated evaluation
+
+Date: 2026-05-03
+
+## Scope
+
+This validation plan covers the integrated cross-media path after the new source selectors and output modes are available. It intentionally starts offline and deterministic, then documents the live source/LLM runs to execute when a local model and network access are explicitly enabled.
+
+The evaluation must vary all of these dimensions:
+
+- Theme: reflective creation, implementation report, company vision, Zenn tutorial, Qiita how-to, homepage copy.
+- Medium: note, Cor.inc company blog, Zenn, Qiita, homepage HTML section.
+- Style: essay, technical report, vision sharing, tutorial, practical how-to, concise product section.
+- Length: 400-700字 homepage copy through 3000字 note essay.
+
+## Offline matrix scenario
+
+Command:
+
+```sh
+SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
+```
+
+Expected output:
+
+```text
+media matrix scenario completed
+offline_only=true
+source_selectors=5
+question_template_ids=9
+composed_templates=10
+cases=6
+matrix=tmp/media_matrix/matrix.json
+cases_markdown=tmp/media_matrix/cases.md
+```
+
+The command does not call public sources or an LLM. It verifies:
+
+- Seeded personas expose these proven selectors:
+ - `note:cor_instrument`
+ - `zenn:cloudia`
+ - `qiita:Cloudia_Cor_Inc`
+ - `rss:https://cor-jp.com/rss.xml`
+ - `github:Cor-Incorporated/corsweb2024/src/content/blog/ja`
+- The baseline Terisuke note interview template still contains the required brief fields.
+- All 10 composed templates across 2 personas x 5 formats are generated, including Cloudia technical questions and homepage CTA questions.
+- Each case can assemble a completed `ArticleBrief` through the domain session flow using its own `persona_id x output_format_id` question template.
+- Each case prompt contains the expected persona, output format, theme, target length, and medium-specific guide fragments.
+
+Artifacts:
+
+- `tmp/media_matrix/matrix.json`: machine-readable planned run matrix.
+- `tmp/media_matrix/cases.md`: human-readable matrix and per-case live LLM command.
+- `tmp/media_matrix/briefs/*.json`: deterministic `ArticleBrief` inputs for draft generation.
+- `tmp/media_matrix/prompts/*.prompt.md`: generated prompts for inspection.
+
+## Matrix cases
+
+The offline scenario currently covers:
+
+| Case | Medium | Style | Primary selectors |
+|---|---|---|---|
+| `terisuke_note_essay` | note | reflective essay | `note:cor_instrument` |
+| `cor_blog_technical_report` | Cor.inc company blog | technical report | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cor_blog_vision_sharing` | Cor.inc company blog | vision sharing | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cloudia_zenn_tutorial` | Zenn | tutorial | `zenn:cloudia` |
+| `cloudia_qiita_how_to` | Qiita | practical how-to | `qiita:Cloudia_Cor_Inc` |
+| `cor_homepage_section` | homepage | concise product section | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+
+## Optional live source phase
+
+Run only when live network validation is intended:
+
+```sh
+RUN_SOURCE_FETCH_SCENARIO=1 \
+SCENARIO_OUTPUT_DIR=tmp/source_fetch_media_matrix \
+SOURCE_FETCH_LIMIT=100 \
+SOURCE_FETCH_NOTE=note:cor_instrument \
+SOURCE_FETCH_ZENN=zenn:cloudia \
+SOURCE_FETCH_QIITA=qiita:Cloudia_Cor_Inc \
+SOURCE_FETCH_RSS=rss:https://cor-jp.com/rss.xml \
+SOURCE_FETCH_GITHUB=github:Cor-Incorporated/corsweb2024/src/content/blog/ja \
+go run ./cmd/scenario/source_fetch
+```
+
+2026-05-03 live result:
+
+| Selector | Articles | Avg content length | Elapsed seconds | Output |
+|---|---:|---:|---:|---|
+| `note:cor_instrument` | 20 | 5,177 chars | 8.91 | `tmp/source_fetch_media_matrix/note.json` |
+| `zenn:cloudia` | 7 | 4,139 chars | 1.06 | `tmp/source_fetch_media_matrix/zenn.json` |
+| `qiita:Cloudia_Cor_Inc` | 12 | 3,274 chars | 0.30 | `tmp/source_fetch_media_matrix/qiita.json` |
+| `rss:https://cor-jp.com/rss.xml` | 10 | 69 chars | 0.10 | `tmp/source_fetch_media_matrix/rss.json` |
+| `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` | 10 | 4,218 chars | 2.61 | `tmp/source_fetch_media_matrix/github.json` |
+
+## Optional live LLM phase
+
+First generate the offline matrix so the brief files exist:
+
+```sh
+SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
+```
+
+Then run draft generation for one case at a time with an available local LLM and style artifacts. Example:
+
+```sh
+RUN_LOCAL_LLM_SCENARIO=1 \
+AUTHOR_PROFILE_PATH=tmp/author_style/profile.json \
+WRITING_GUIDE_PATH=tmp/author_style/guide.json \
+ARTICLE_BRIEF_PATH=tmp/media_matrix/briefs/cloudia_zenn_tutorial.json \
+SCENARIO_OUTPUT_DIR=tmp/media_matrix/live/cloudia_zenn_tutorial \
+go run ./cmd/scenario/draft_generation
+```
+
+Use the `planned_llm_command` entries in `tmp/media_matrix/matrix.json` or `tmp/media_matrix/cases.md` for the remaining cases, adding `AUTHOR_PROFILE_PATH` and `WRITING_GUIDE_PATH` to point at the style artifacts for the phase under test.
+
+## Comparison table
+
+Compare results across phases and cases with this schema:
+
+| Case | Phase | Medium | Style | Target length | Elapsed seconds | Score | Verification passed | Runes | Output |
+|---|---|---|---|---|---:|---:|---|---:|---|
+| `terisuke_note_essay` | offline matrix | note | reflective essay | 3000字前後 | | | prompt checks only | | `tmp/media_matrix/prompts/terisuke_note_essay.prompt.md` |
+| `terisuke_note_essay` | live draft | note | reflective essay | 3000字前後 | | | | | |
+| `cor_blog_technical_report` | live draft | company blog | technical report | 2200-2800字 | | | | | |
+| `cor_blog_vision_sharing` | live draft | company blog | vision sharing | 1600-2200字 | | | | | |
+| `cloudia_zenn_tutorial` | live draft | Zenn | tutorial | 1800-2400字 | | | | | |
+| `cloudia_qiita_how_to` | live draft | Qiita | practical how-to | 1400-2000字 | | | | | |
+| `cor_homepage_section` | live draft | homepage | concise product section | 400-700字 | | | | | |
+
+Acceptance criteria for the integrated evaluation:
+
+- Offline matrix passes before any live work.
+- Live source phase confirms each selector still returns usable material, with GitHub Markdown preferred over RSS for full Cor.inc blog body text.
+- Each live draft records `elapsed_seconds`, style `score`, `passed`, `verification_performed`, `verification_passed`, and `runes` from `cmd/scenario/draft_generation`.
+- Failures are grouped by dimension: source selector, persona, medium/output format, style, target length, or verifier result.
diff --git a/internal/application/brief/service.go b/internal/application/brief/service.go
index 5b76f1c..19aafb9 100644
--- a/internal/application/brief/service.go
+++ b/internal/application/brief/service.go
@@ -6,6 +6,8 @@ import (
"strings"
domain "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"
)
// FollowUpGenerator can phrase deep-dive questions; services fall back to domain rules on error.
@@ -53,10 +55,16 @@ func NewInterviewService(followUpGenerator FollowUpGenerator) *InterviewService
// StartSession creates a session and returns the first fixed question.
func (s *InterviewService) StartSession(input StartSessionInput) (InterviewResult, error) {
- if len(input.Questions) == 0 {
- input.Questions = domain.FixedQuestions()
+ personaID := personadomain.NormalizeID(input.PersonaID)
+ formatID := outputformat.NormalizeID(input.OutputFormatID)
+ if strings.TrimSpace(input.OutputFormatID) == "" {
+ if persona, ok := personadomain.DefaultRegistry().Get(personaID); ok {
+ formatID = persona.DefaultFormat
+ }
}
- session, err := domain.NewArticleBriefSessionWithOptions(input.SessionID, input.StyleProfileID, input.PersonaID, input.OutputFormatID, "", input.Questions)
+ questions := domain.ComposeFixedQuestions(personaID, formatID)
+ questions = append(questions, input.Questions...)
+ session, err := domain.NewArticleBriefSessionWithOptions(input.SessionID, input.StyleProfileID, personaID, formatID, "", questions)
if err != nil {
return InterviewResult{}, err
}
diff --git a/internal/application/brief/service_test.go b/internal/application/brief/service_test.go
index 23a012e..9227b7a 100644
--- a/internal/application/brief/service_test.go
+++ b/internal/application/brief/service_test.go
@@ -6,6 +6,8 @@ import (
"testing"
domain "github.com/teradakousuke/note_maker/internal/domain/brief"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ "github.com/teradakousuke/note_maker/internal/domain/persona"
)
func TestInterviewServiceStartsAndCompletesWorkflow(t *testing.T) {
@@ -151,6 +153,55 @@ func TestInterviewServiceForksEditedAnswerAndReturnsNextQuestion(t *testing.T) {
}
}
+func TestInterviewServiceAppendsCustomQuestionsAfterComposedTemplate(t *testing.T) {
+ service := NewInterviewService(nil)
+ result, err := service.StartSession(StartSessionInput{
+ SessionID: "session-custom",
+ StyleProfileID: "style-1",
+ PersonaID: persona.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ Questions: []domain.ArticleQuestion{
+ {
+ ID: "custom_reference",
+ Text: "参考リンクとして必ず確認するURLは何ですか?",
+ FlowType: domain.QuestionFlowMain,
+ TargetField: "custom",
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("start session: %v", err)
+ }
+ template := domain.ComposeFixedQuestions(persona.IDCloudia, outputformat.IDZennArticle)
+ if len(result.Session.Questions) != len(template)+1 {
+ t.Fatalf("question count = %d, want %d", len(result.Session.Questions), len(template)+1)
+ }
+ if result.Session.Questions[len(template)-1].ID != domain.QuestionIDCloudiaViewpoint {
+ t.Fatalf("last template question = %#v", result.Session.Questions[len(template)-1])
+ }
+ if result.Session.Questions[len(result.Session.Questions)-1].ID != "custom_reference" {
+ t.Fatalf("custom question was not appended last: %#v", result.Session.Questions)
+ }
+}
+
+func TestInterviewServiceUsesPersonaDefaultFormatForTemplate(t *testing.T) {
+ service := NewInterviewService(nil)
+ result, err := service.StartSession(StartSessionInput{
+ SessionID: "session-cloudia-default",
+ StyleProfileID: "style-1",
+ PersonaID: persona.IDCloudia,
+ })
+ if err != nil {
+ t.Fatalf("start session: %v", err)
+ }
+ if result.Session.OutputFormatID != outputformat.IDZennArticle {
+ t.Fatalf("output format = %q, want %q", result.Session.OutputFormatID, outputformat.IDZennArticle)
+ }
+ if !sessionHasQuestion(result.Session.Questions, domain.QuestionIDTargetStack) {
+ t.Fatalf("cloudia default template missing target_stack: %#v", result.Session.Questions)
+ }
+}
+
func fixedAnswers() []string {
return []string{
"Local article generation with a small deterministic workflow.",
@@ -165,6 +216,15 @@ func fixedAnswers() []string {
}
}
+func sessionHasQuestion(questions []domain.ArticleQuestion, id string) bool {
+ for _, question := range questions {
+ if question.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
type staticFollowUpGenerator struct {
text string
err error
diff --git a/internal/domain/brief/session_test.go b/internal/domain/brief/session_test.go
index e112068..d08ca5d 100644
--- a/internal/domain/brief/session_test.go
+++ b/internal/domain/brief/session_test.go
@@ -1,8 +1,12 @@
package brief
import (
+ "reflect"
"strings"
"testing"
+
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ "github.com/teradakousuke/note_maker/internal/domain/persona"
)
func TestFixedQuestionsAreDeterministic(t *testing.T) {
@@ -45,6 +49,48 @@ func TestFixedQuestionsAreDeterministic(t *testing.T) {
}
}
+func TestComposeFixedQuestionsCoversPersonasAndFormats(t *testing.T) {
+ personas := []string{persona.IDTerisuke, persona.IDCloudia}
+ formats := []string{
+ outputformat.IDNoteArticle,
+ outputformat.IDMarkdownBlog,
+ outputformat.IDZennArticle,
+ outputformat.IDQiitaArticle,
+ outputformat.IDHomepageSection,
+ }
+ for _, personaID := range personas {
+ for _, formatID := range formats {
+ t.Run(personaID+"_"+formatID, func(t *testing.T) {
+ questions := ComposeFixedQuestions(personaID, formatID)
+ if len(questions) < len(FixedQuestions()) {
+ t.Fatalf("question count = %d, want at least %d", len(questions), len(FixedQuestions()))
+ }
+ assertUniqueQuestionIDs(t, questions)
+ if personaID == persona.IDTerisuke && formatID == outputformat.IDNoteArticle {
+ if !reflect.DeepEqual(questions, FixedQuestions()) {
+ t.Fatalf("terisuke note_article template changed:\ngot %#v\nwant %#v", questions, FixedQuestions())
+ }
+ return
+ }
+ switch formatID {
+ case outputformat.IDNoteArticle:
+ assertQuestionPresent(t, questions, QuestionIDStoryArc)
+ case outputformat.IDMarkdownBlog, outputformat.IDZennArticle, outputformat.IDQiitaArticle:
+ assertQuestionPresent(t, questions, QuestionIDTargetStack)
+ case outputformat.IDHomepageSection:
+ assertQuestionPresent(t, questions, QuestionIDHomepageCTA)
+ }
+ if personaID == persona.IDCloudia {
+ assertQuestionPresent(t, questions, QuestionIDCloudiaViewpoint)
+ }
+ if personaID == persona.IDCloudia && formatID == outputformat.IDZennArticle {
+ assertQuestionPresent(t, questions, QuestionIDTargetStack)
+ }
+ })
+ }
+ }
+}
+
func TestSelectDeepDiveTargetsUsesHighValueDeterministicOrder(t *testing.T) {
session := answeredFixedSession(t, map[string]string{
QuestionIDOpeningEpisode: "The article opens on a late night debugging session.",
@@ -294,3 +340,24 @@ func answeredFixedSession(t *testing.T, overrides map[string]string) ArticleBrie
}
return session
}
+
+func assertQuestionPresent(t *testing.T, questions []ArticleQuestion, id string) {
+ t.Helper()
+ for _, question := range questions {
+ if question.ID == id {
+ return
+ }
+ }
+ t.Fatalf("question %q was not present in %#v", id, questions)
+}
+
+func assertUniqueQuestionIDs(t *testing.T, questions []ArticleQuestion) {
+ t.Helper()
+ seen := map[string]bool{}
+ for _, question := range questions {
+ if seen[question.ID] {
+ t.Fatalf("duplicate question id %q in %#v", question.ID, questions)
+ }
+ seen[question.ID] = true
+ }
+}
diff --git a/internal/domain/brief/types.go b/internal/domain/brief/types.go
index b99429f..e357d20 100644
--- a/internal/domain/brief/types.go
+++ b/internal/domain/brief/types.go
@@ -18,6 +18,12 @@ const (
QuestionIDExclusions = "exclusions"
QuestionIDTargetLengthStructure = "target_length_structure"
QuestionIDToneStance = "tone_stance"
+ QuestionIDStoryArc = "story_arc"
+ QuestionIDTargetStack = "target_stack"
+ QuestionIDTechnicalProof = "technical_proof"
+ QuestionIDHomepageCTA = "homepage_cta"
+ QuestionIDHomepageTrust = "homepage_trust"
+ QuestionIDCloudiaViewpoint = "cloudia_viewpoint"
MaxFollowUpsPerTarget = 2
MaxTotalFollowUps = 4
@@ -222,3 +228,102 @@ func FixedQuestions() []ArticleQuestion {
},
}
}
+
+// ComposeFixedQuestions returns the server-side interview template for a persona and output format.
+func ComposeFixedQuestions(personaID, outputFormatID string) []ArticleQuestion {
+ personaID = persona.NormalizeID(personaID)
+ if strings.TrimSpace(outputFormatID) == "" {
+ if item, ok := persona.DefaultRegistry().Get(personaID); ok {
+ outputFormatID = item.DefaultFormat
+ }
+ }
+ outputFormatID = outputformat.NormalizeID(outputFormatID)
+ if personaID == persona.IDTerisuke && outputFormatID == outputformat.IDNoteArticle {
+ return FixedQuestions()
+ }
+
+ questions := FixedQuestions()
+ questions = append(questions, formatExtensionQuestions(outputFormatID)...)
+ questions = append(questions, personaExtensionQuestions(personaID)...)
+ return NormalizeQuestions(questions)
+}
+
+func formatExtensionQuestions(outputFormatID string) []ArticleQuestion {
+ switch outputFormatID {
+ case outputformat.IDNoteArticle:
+ return narrativeExtensionQuestions()
+ case outputformat.IDMarkdownBlog, outputformat.IDZennArticle, outputformat.IDQiitaArticle:
+ return technicalExtensionQuestions()
+ case outputformat.IDHomepageSection:
+ return homepageExtensionQuestions()
+ default:
+ return nil
+ }
+}
+
+func narrativeExtensionQuestions() []ArticleQuestion {
+ return []ArticleQuestion{
+ {
+ ID: QuestionIDStoryArc,
+ Text: "読み物として印象に残すため、どんな感情の流れやオチを置きますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ }
+}
+
+func technicalExtensionQuestions() []ArticleQuestion {
+ return []ArticleQuestion{
+ {
+ ID: QuestionIDTargetStack,
+ Text: "対象にする技術スタック、言語、ライブラリ、実行環境、前提バージョンは何ですか?",
+ FlowType: QuestionFlowMain,
+ Required: true,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDTechnicalProof,
+ Text: "記事内で示す再現手順、コード例、検証結果、失敗例は何ですか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ }
+}
+
+func homepageExtensionQuestions() []ArticleQuestion {
+ return []ArticleQuestion{
+ {
+ ID: QuestionIDHomepageCTA,
+ Text: "このWebセクションを読んだ人に押してほしいCTAや次の行動は何ですか?",
+ FlowType: QuestionFlowMain,
+ Required: true,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDHomepageTrust,
+ Text: "サービスや会社への信頼につながる実績、根拠、約束として何を入れますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ }
+}
+
+func personaExtensionQuestions(personaID string) []ArticleQuestion {
+ switch personaID {
+ case persona.IDCloudia:
+ return []ArticleQuestion{
+ {
+ ID: QuestionIDCloudiaViewpoint,
+ Text: "クラウディアならではの視点や感想として、どんな驚き、楽しさ、つまずきを入れますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ }
+ default:
+ return nil
+ }
+}
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index f89ba0e..b51853a 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -95,6 +95,12 @@ type briefSessionResponse struct {
Answers []briefdomain.BriefAnswer `json:"answers"`
}
+type briefSessionTemplateResponse struct {
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Questions []articleQuestionJSON `json:"questions"`
+}
+
type articleQuestionJSON struct {
ID string `json:"id"`
Text string `json:"text"`
@@ -145,6 +151,29 @@ func ListFormatsHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, outputformat.DefaultRegistry().List())
}
+// 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"))
+ if !ok {
+ respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", r.URL.Query().Get("persona_id"), http.StatusBadRequest)
+ return
+ }
+ formatID := outputformat.NormalizeID(r.URL.Query().Get("format_id"))
+ if strings.TrimSpace(r.URL.Query().Get("format_id")) == "" {
+ formatID = persona.DefaultFormat
+ }
+ format, ok := outputformat.DefaultRegistry().Get(formatID)
+ if !ok {
+ respondWithError(w, "UNKNOWN_OUTPUT_FORMAT", "Output format was not found", formatID, http.StatusBadRequest)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, briefSessionTemplateResponse{
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Questions: toArticleQuestionJSONList(briefdomain.ComposeFixedQuestions(persona.ID, format.ID)),
+ })
+}
+
// SeedAuthorStyleHandler stores a practical persona preset as a style guide.
func SeedAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
var req seedAuthorStyleRequest
@@ -831,14 +860,8 @@ func toAuthorStyleResponse(result authorstyleapp.AnalyzeResult) authorStyleRespo
func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionResponse {
var question *articleQuestionJSON
if result.NextQuestion != nil {
- question = &articleQuestionJSON{
- ID: result.NextQuestion.ID,
- Text: result.NextQuestion.Text,
- FlowType: string(result.NextQuestion.FlowType),
- TargetField: result.NextQuestion.TargetField,
- TargetQuestionID: result.NextQuestion.TargetQuestionID,
- FollowUpIndex: result.NextQuestion.FollowUpIndex,
- }
+ value := toArticleQuestionJSON(*result.NextQuestion)
+ question = &value
}
return briefSessionResponse{
SessionID: result.Session.ID,
@@ -854,6 +877,25 @@ func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionRespons
}
}
+func toArticleQuestionJSONList(questions []briefdomain.ArticleQuestion) []articleQuestionJSON {
+ result := make([]articleQuestionJSON, 0, len(questions))
+ for _, question := range questions {
+ result = append(result, toArticleQuestionJSON(question))
+ }
+ return result
+}
+
+func toArticleQuestionJSON(question briefdomain.ArticleQuestion) articleQuestionJSON {
+ return articleQuestionJSON{
+ ID: question.ID,
+ Text: question.Text,
+ FlowType: string(question.FlowType),
+ TargetField: question.TargetField,
+ TargetQuestionID: question.TargetQuestionID,
+ FollowUpIndex: question.FollowUpIndex,
+ }
+}
+
func newID(prefix string) string {
var bytes [6]byte
if _, err := rand.Read(bytes[:]); err != nil {
diff --git a/internal/handlers/workflow_handler_test.go b/internal/handlers/workflow_handler_test.go
index 613209d..e9e4957 100644
--- a/internal/handlers/workflow_handler_test.go
+++ b/internal/handlers/workflow_handler_test.go
@@ -103,6 +103,33 @@ func TestCreateBriefSessionHandlerCreatesAndPersistsSession(t *testing.T) {
}
}
+func TestCreateBriefSessionHandlerAppendsCustomQuestionsAfterTemplate(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session-custom","persona_id":"cloudia","output_format_id":"zenn_article","questions":[{"id":"custom_reference","text":"参考リンクとして必ず確認するURLは何ですか?"}]}`
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions", bytes.NewBufferString(body))
+ response := httptest.NewRecorder()
+
+ CreateBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ session, ok := workflowStore.GetSession("session-custom")
+ if !ok {
+ t.Fatal("created session was not saved")
+ }
+ template := briefdomain.ComposeFixedQuestions(personadomain.IDCloudia, outputformat.IDZennArticle)
+ if len(session.Questions) != len(template)+1 {
+ t.Fatalf("question count = %d, want %d", len(session.Questions), len(template)+1)
+ }
+ if session.Questions[len(template)-1].ID != briefdomain.QuestionIDCloudiaViewpoint {
+ t.Fatalf("last template question = %#v", session.Questions[len(template)-1])
+ }
+ if session.Questions[len(session.Questions)-1].ID != "custom_reference" {
+ t.Fatalf("custom question was not appended after template: %#v", session.Questions)
+ }
+}
+
func TestCreateBriefSessionHandlerValidatesInputs(t *testing.T) {
style := setupWorkflowStyle(t)
tests := []struct {
diff --git a/internal/handlers/workflow_mode_test.go b/internal/handlers/workflow_mode_test.go
index 6ae3102..3d8de57 100644
--- a/internal/handlers/workflow_mode_test.go
+++ b/internal/handlers/workflow_mode_test.go
@@ -5,6 +5,10 @@ import (
"net/http"
"net/http/httptest"
"testing"
+
+ 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 TestListPersonasHandler(t *testing.T) {
@@ -53,3 +57,39 @@ func TestListFormatsHandler(t *testing.T) {
t.Fatalf("zenn_article format with metadata requirement not found: %#v", payload)
}
}
+
+func TestGetBriefSessionTemplateHandlerReturnsComposedQuestions(t *testing.T) {
+ response := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/api/brief-sessions/templates?persona_id=cloudia&format_id=zenn_article", nil)
+
+ GetBriefSessionTemplateHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
+ }
+ var payload briefSessionTemplateResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode template: %v", err)
+ }
+ if payload.PersonaID != personadomain.IDCloudia || payload.OutputFormatID != outputformat.IDZennArticle {
+ t.Fatalf("unexpected template identity: %#v", payload)
+ }
+ if len(payload.Questions) != len(briefdomain.ComposeFixedQuestions(personadomain.IDCloudia, outputformat.IDZennArticle)) {
+ t.Fatalf("question count = %d", len(payload.Questions))
+ }
+ if !hasQuestionJSON(payload.Questions, briefdomain.QuestionIDTargetStack) {
+ t.Fatalf("target_stack missing from template: %#v", payload.Questions)
+ }
+ if !hasQuestionJSON(payload.Questions, briefdomain.QuestionIDCloudiaViewpoint) {
+ t.Fatalf("cloudia viewpoint missing from template: %#v", payload.Questions)
+ }
+}
+
+func hasQuestionJSON(questions []articleQuestionJSON, id string) bool {
+ for _, question := range questions {
+ if question.ID == id {
+ return true
+ }
+ }
+ return false
+}
diff --git a/static/css/style.css b/static/css/style.css
index 4a33a03..d4905ca 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -169,6 +169,34 @@ body {
min-width: 0;
}
+.question-config-row.template input {
+ background: #f8fafc;
+ color: var(--muted);
+}
+
+.question-config-tag {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: var(--muted);
+ font-size: 0.85rem;
+ white-space: nowrap;
+}
+
+.question-config-status {
+ border: 1px dashed var(--line);
+ border-radius: 6px;
+ padding: 10px 12px;
+ color: var(--muted);
+ background: #fff;
+}
+
+.question-config-status.error {
+ border-color: #fecaca;
+ color: #b91c1c;
+ background: #fef2f2;
+}
+
label {
font-weight: 600;
}
diff --git a/static/js/script.js b/static/js/script.js
index d7107fb..aeac617 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -1,16 +1,33 @@
document.addEventListener('DOMContentLoaded', () => {
const configStorageKey = 'note-maker-config-v1';
- const defaultQuestions = [
- { id: 'theme', text: '記事の中心テーマは何ですか?', flow_type: 'main', target_field: 'theme' },
- { id: 'opening_episode', text: '記事の導入に置く具体的な体験や場面は何ですか?', flow_type: 'main', target_field: 'opening_episode' },
- { id: 'reader', text: 'この記事を届けたい読者は誰ですか?', flow_type: 'main', target_field: 'reader' },
- { id: 'expected_reader_action', text: '読後に読者へどんな変化や行動を起こしてほしいですか?', flow_type: 'main', target_field: 'expected_reader_action' },
- { id: 'must_include', text: '記事に必ず含める論点、事実、手順は何ですか?', flow_type: 'main', target_field: 'must_include' },
- { id: 'personal_context', text: '著者本人の経験、肩書き、失敗、価値観など、記事に入れるべき属人的な文脈は何ですか?', flow_type: 'main', target_field: 'personal_context' },
- { id: 'exclusions', text: '記事に含めないこと、避けたい表現、断言しないことは何ですか?', flow_type: 'main', target_field: 'exclusions' },
- { id: 'target_length_structure', text: '目標文字数と記事構成を指定してください。例: 3000字前後、導入・背景・実装・検証・提案・結論。', flow_type: 'main', target_field: 'target_length_structure' },
- { id: 'tone_stance', text: '記事のトーンや立場はどうしますか?内省、技術解説、実用、物語性の比重も指定してください。', flow_type: 'main', target_field: 'tone_stance' },
- ];
+ const legacyTemplateQuestionIds = new Set([
+ 'theme',
+ 'opening_episode',
+ 'reader',
+ 'expected_reader_action',
+ 'must_include',
+ 'personal_context',
+ 'exclusions',
+ 'target_length_structure',
+ 'tone_stance',
+ 'cor_blog_purpose',
+ 'cor_blog_category',
+ 'cor_blog_metadata',
+ 'cor_blog_evidence',
+ 'cor_blog_next_action',
+ 'target_stack',
+ 'prerequisite_knowledge',
+ 'code_examples',
+ 'references',
+ 'target_conversion',
+ 'primary_cta',
+ 'brand_voice',
+ 'story_arc',
+ 'technical_proof',
+ 'homepage_cta',
+ 'homepage_trust',
+ 'cloudia_viewpoint',
+ ]);
const config = loadConfig();
const state = {
@@ -22,6 +39,10 @@ document.addEventListener('DOMContentLoaded', () => {
completedBrief: null,
personas: [],
formats: [],
+ templateQuestions: [],
+ templateLoading: false,
+ templateError: '',
+ templateRequestId: 0,
questionTextById: {},
lastSubmittedAnswer: '',
answerAbortController: null,
@@ -127,6 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
populateFormatSelect();
applyPersonaDefaults(false);
renderModeSummary();
+ loadQuestionTemplate();
} catch (error) {
showError(`書き分け設定の取得に失敗しました: ${error.message}`);
}
@@ -223,7 +245,7 @@ document.addEventListener('DOMContentLoaded', () => {
state.nextQuestion = data.next_question;
state.answers = data.answers || [];
state.completedBrief = null;
- rememberQuestions(currentQuestions());
+ rememberQuestions([...state.templateQuestions, ...currentQuestions()]);
rememberQuestion(data.next_question);
el.interviewArea.classList.remove('hidden');
el.briefResult.classList.add('hidden');
@@ -676,13 +698,14 @@ document.addEventListener('DOMContentLoaded', () => {
applyPersonaDefaults(true);
saveConfig();
renderModeSummary();
+ loadQuestionTemplate();
}
function onFormatChange() {
config.mode.format = currentFormatId();
saveConfig();
renderModeSummary();
- renderQuestionConfig();
+ loadQuestionTemplate();
}
function applyPersonaDefaults(forceFormat) {
@@ -698,7 +721,6 @@ document.addEventListener('DOMContentLoaded', () => {
el.formatSelect.value = persona.default_format;
config.mode.format = persona.default_format;
}
- renderQuestionConfig();
}
function renderModeSummary() {
@@ -739,38 +761,124 @@ document.addEventListener('DOMContentLoaded', () => {
saveConfig();
}
+ async function loadQuestionTemplate() {
+ const personaId = currentPersonaId();
+ const formatId = currentFormatId();
+ if (!personaId || !formatId) {
+ state.templateQuestions = [];
+ state.templateError = '';
+ state.templateLoading = false;
+ renderQuestionConfig();
+ return;
+ }
+
+ const requestId = state.templateRequestId + 1;
+ state.templateRequestId = requestId;
+ state.templateLoading = true;
+ state.templateError = '';
+ state.templateQuestions = [];
+ renderQuestionConfig();
+
+ try {
+ const params = new URLSearchParams({ persona_id: personaId, format_id: formatId });
+ const data = await requestJSON(`/api/brief-sessions/templates?${params.toString()}`);
+ if (requestId !== state.templateRequestId) {
+ return;
+ }
+ state.templateQuestions = normalizeTemplateQuestions(data);
+ rememberQuestions(state.templateQuestions);
+ } catch (error) {
+ if (requestId !== state.templateRequestId) {
+ return;
+ }
+ state.templateQuestions = [];
+ state.templateError = `テンプレート質問を取得できませんでした: ${error.message}`;
+ } finally {
+ if (requestId === state.templateRequestId) {
+ state.templateLoading = false;
+ renderQuestionConfig();
+ }
+ }
+ }
+
function renderQuestionConfig() {
el.questionConfigList.innerHTML = '';
- config.questions.forEach((question, index) => {
- const row = document.createElement('div');
- row.className = 'question-config-row';
-
- const input = document.createElement('input');
- input.type = 'text';
- input.value = question.text;
- input.addEventListener('input', () => {
- config.questions[index].text = input.value;
- saveConfig();
- });
- const remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'secondary-btn';
- remove.textContent = '削除';
- remove.disabled = isFixedQuestion(question.id);
- remove.addEventListener('click', () => {
- config.questions.splice(index, 1);
- saveConfig();
- renderQuestionConfig();
- });
+ if (state.templateLoading) {
+ el.questionConfigList.appendChild(createQuestionConfigStatus('テンプレート質問を読み込んでいます...'));
+ }
+
+ state.templateQuestions.forEach((question) => {
+ el.questionConfigList.appendChild(createTemplateQuestionRow(question));
+ });
+
+ if (state.templateError) {
+ el.questionConfigList.appendChild(createQuestionConfigStatus(state.templateError, true));
+ }
+
+ config.customQuestions.forEach((question, index) => {
+ el.questionConfigList.appendChild(createCustomQuestionRow(question, index));
+ });
+
+ if (!state.templateLoading && !state.templateQuestions.length && !state.templateError && !config.customQuestions.length) {
+ el.questionConfigList.appendChild(createQuestionConfigStatus('テンプレート質問はありません。追加質問を入力できます。'));
+ }
+ }
+
+ function createTemplateQuestionRow(question) {
+ const row = document.createElement('div');
+ row.className = 'question-config-row template';
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = question.text;
+ input.readOnly = true;
+ input.setAttribute('aria-label', 'テンプレート質問');
+
+ const label = document.createElement('span');
+ label.className = 'question-config-tag';
+ label.textContent = 'テンプレート';
+
+ row.append(input, label);
+ return row;
+ }
+
+ function createCustomQuestionRow(question, index) {
+ const row = document.createElement('div');
+ row.className = 'question-config-row custom';
- row.append(input, remove);
- el.questionConfigList.appendChild(row);
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = question.text;
+ input.setAttribute('aria-label', '追加質問');
+ input.addEventListener('input', () => {
+ config.customQuestions[index].text = input.value;
+ saveConfig();
});
+
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'secondary-btn';
+ remove.textContent = '削除';
+ remove.addEventListener('click', () => {
+ config.customQuestions.splice(index, 1);
+ saveConfig();
+ renderQuestionConfig();
+ });
+
+ row.append(input, remove);
+ return row;
+ }
+
+ function createQuestionConfigStatus(message, isError = false) {
+ const status = document.createElement('div');
+ status.className = `question-config-status${isError ? ' error' : ''}`;
+ status.textContent = message;
+ return status;
}
function addQuestion() {
- config.questions.push({
+ config.customQuestions.push({
id: `custom_${Date.now()}`,
text: '追加で聞きたい質問を入力してください',
flow_type: 'main',
@@ -781,48 +889,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
function resetQuestions() {
- config.questions = cloneQuestions(defaultQuestions);
+ config.customQuestions = [];
saveConfig();
- renderQuestionConfig();
+ loadQuestionTemplate();
}
function currentQuestions() {
- return [...config.questions, ...formatQuestions(currentFormatId())]
- .map((question) => ({
- id: question.id,
- text: question.text.trim(),
- flow_type: question.flow_type || 'main',
- target_field: question.target_field || 'custom',
- }))
- .filter((question) => question.id && question.text);
- }
-
- function formatQuestions(formatId) {
- if (formatId === 'markdown_blog') {
- return [
- { id: 'cor_blog_purpose', text: '会社ブログとしての主目的は何ですか?例: 技術知見の報告、実装判断の共有、社員へのビジョン共有。', flow_type: 'main', target_field: 'custom' },
- { id: 'cor_blog_category', text: 'カテゴリは ai / engineering / founder / lab のどれにしますか?理由も教えてください。', flow_type: 'main', target_field: 'custom' },
- { id: 'cor_blog_metadata', text: 'slug候補、タグ3-5個、featuredの有無、画像パスがあれば指定してください。', flow_type: 'main', target_field: 'custom' },
- { id: 'cor_blog_evidence', text: '本文に入れる具体的な実装、検証結果、数値、意思決定の根拠は何ですか?', flow_type: 'main', target_field: 'custom' },
- { id: 'cor_blog_next_action', text: '社員や読者に、この記事を読んだ後どんな判断や行動をしてほしいですか?', flow_type: 'main', target_field: 'custom' },
- ];
- }
- if (formatId === 'zenn_article' || formatId === 'qiita_article') {
- return [
- { id: 'target_stack', text: '対象技術スタック、バージョン、実行環境は何ですか?', flow_type: 'main', target_field: 'custom' },
- { id: 'prerequisite_knowledge', text: '読者に前提として求める知識と、説明を厚くする箇所はどこですか?', flow_type: 'main', target_field: 'custom' },
- { id: 'code_examples', text: '必ず入れるコード例、コマンド、設定ファイルは何ですか?', flow_type: 'main', target_field: 'custom' },
- { id: 'references', text: '参照すべき公式ドキュメント、記事、リポジトリURLはありますか?', flow_type: 'main', target_field: 'custom' },
- ];
- }
- if (formatId === 'homepage_section') {
- return [
- { id: 'target_conversion', text: 'このHTMLセクションで読者に起こしてほしい行動は何ですか?', flow_type: 'main', target_field: 'custom' },
- { id: 'primary_cta', text: 'CTAの文言とリンク先は何にしますか?', flow_type: 'main', target_field: 'custom' },
- { id: 'brand_voice', text: '会社サイトとして守りたい言い回し、避けたい表現はありますか?', flow_type: 'main', target_field: 'custom' },
- ];
- }
- return [];
+ const templateIds = new Set(state.templateQuestions.map((question) => question.id));
+ const templateTexts = new Set(state.templateQuestions.map((question) => question.text.trim()).filter(Boolean));
+ return normalizeQuestionList(config.customQuestions)
+ .filter((question) => question.id && question.text && !templateIds.has(question.id) && !templateTexts.has(question.text));
}
function currentPersonaId() {
@@ -841,24 +917,31 @@ document.addEventListener('DOMContentLoaded', () => {
return state.formats.find((format) => format.id === currentFormatId());
}
- function isFixedQuestion(id) {
- return defaultQuestions.some((question) => question.id === id);
+ function normalizeTemplateQuestions(data) {
+ const values = Array.isArray(data)
+ ? data
+ : data?.questions || data?.template_questions || data?.template?.questions || data?.Template?.Questions || [];
+ if (!Array.isArray(values)) {
+ return [];
+ }
+ return normalizeQuestionList(values)
+ .map((question) => ({ ...question, template: true }))
+ .filter((question) => question.id && question.text);
}
function loadConfig() {
const fallback = {
mode: { persona: 'terisuke', format: 'note_article' },
models: { style: 'gemma4:e2b', brief: 'qwen3.6:27b', draft: 'gemma4:31b', verify: 'gemma4:latest' },
- questions: cloneQuestions(defaultQuestions),
+ customQuestions: [],
};
try {
const saved = JSON.parse(localStorage.getItem(configStorageKey) || '{}');
+ const savedCustomQuestions = saved.customQuestions || saved.custom_questions || migrateLegacyQuestions(saved.questions);
return {
models: { ...fallback.models, ...(saved.models || {}) },
mode: { ...fallback.mode, ...(saved.mode || {}) },
- questions: Array.isArray(saved.questions) && saved.questions.length
- ? saved.questions
- : fallback.questions,
+ customQuestions: normalizeQuestionList(savedCustomQuestions),
};
} catch (_) {
return fallback;
@@ -869,8 +952,32 @@ document.addEventListener('DOMContentLoaded', () => {
localStorage.setItem(configStorageKey, JSON.stringify(config));
}
- function cloneQuestions(questions) {
- return questions.map((question) => ({ ...question }));
+ function migrateLegacyQuestions(questions) {
+ if (!Array.isArray(questions)) {
+ return [];
+ }
+ return questions.filter((question) => question?.id && !legacyTemplateQuestionIds.has(question.id));
+ }
+
+ function normalizeQuestionList(questions) {
+ if (!Array.isArray(questions)) {
+ return [];
+ }
+ const seen = new Set();
+ return questions
+ .map((question) => ({
+ id: String(question.id || question.ID || '').trim(),
+ text: String(question.text || question.Text || '').trim(),
+ flow_type: question.flow_type || question.FlowType || 'main',
+ target_field: question.target_field || question.TargetField || 'custom',
+ }))
+ .filter((question) => {
+ if (!question.id || !question.text || seen.has(question.id)) {
+ return false;
+ }
+ seen.add(question.id);
+ return true;
+ });
}
function escapeHTML(value) {
From 2a7b7d57840989424fd308c646d487252cd55293 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 00:27:14 +0900
Subject: [PATCH 17/33] docs: align next implementation plan
---
...02-multi-persona-multi-format-extension.md | 12 +-
.../issue-adr-guardrails.md | 22 +++-
.../multi-persona-multi-format.md | 12 +-
.../next-implementation-cut.md | 106 +++++++++++-------
4 files changed, 98 insertions(+), 54 deletions(-)
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 243e7a1..5e0b4c8 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -195,9 +195,9 @@ The full work is broken into four phases tracked by issues. Each phase is indepe
- Handler test coverage, Issue [#11](https://github.com/terisuke/note_maker/issues/11) (style threshold), Issue [#13](https://github.com/terisuke/note_maker/issues/13) (Playwright), Issue [#15](https://github.com/terisuke/note_maker/issues/15) (desktop packaging) follow-up.
- Issue: [#29](https://github.com/terisuke/note_maker/issues/29).
-Recommended order: A → C → B → D. Phase C is sequenced before the remaining B work because the persona registry needs durable storage to be useful; running the full persona library on the JSON store would force a second migration.
+Original recommended order was A → C → B → D. Implementation intentionally pulled the minimum B work forward because source acquisition, format validation, persona seeds, and question templates were required before a realistic cross-media evaluation could be defined. With Phases A and B now implemented, the next order is C1 + D1 in parallel, then C2/C3, then the full Evo X2 media-matrix evaluation under #40.
-Current implementation status as of 2026-05-02:
+Current implementation status as of 2026-05-03:
- ADR 0001's strict Terisuke style threshold work is complete ([#11](https://github.com/terisuke/note_maker/issues/11)).
- Phase B1 is complete ahead of the original order: `Persona` and `OutputFormat` concepts, prompt dispatch, and format validators are in place ([#21](https://github.com/terisuke/note_maker/issues/21)). The remaining Phase B work stays deferred until after Phase A/C foundations.
@@ -209,13 +209,14 @@ Current implementation status as of 2026-05-02:
- Phase A1 is implemented and merged: the interview surface now renders a chat-style transcript, answer bubbles can be edited inline, and edits create child sessions via fork-on-edit while retaining `parent_session_id` lineage ([#17](https://github.com/terisuke/note_maker/issues/17)).
- Phase A4 is implemented and merged: follow-up prompts include parent question, parent answer, and active style guide context; rule-based fallback questions use the same quoted parent-answer prefix; the transcript labels the parent answer as the deep-dive rationale ([#20](https://github.com/terisuke/note_maker/issues/20)). Validation is recorded in [Issue 20 deep-dive rationale validation](../validation/issue-20-deep-dive-rationale-2026-05-02.md).
- Phase A3 is implemented in code: the generated Markdown textarea is editable, preview rendering live-syncs through `marked`, both preview and Markdown tabs can copy content, and `POST /api/drafts/{id}/regenerate-section` rewrites exactly one `## ` subtree while preserving the rest of the draft byte-for-byte ([#19](https://github.com/terisuke/note_maker/issues/19)). Validation is recorded in [Issue 19 section regeneration validation](../validation/issue-19-section-regeneration-2026-05-02.md).
-- Phase B3/B4 are implemented for the in-repo registry surface: all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Deterministic scenario validation is recorded in [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). Live source-derived guide rebuilding for Zenn/Qiita/RSS remains part of source acquisition work in [#22](https://github.com/terisuke/note_maker/issues/22).
+- 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)).
Near-term execution order:
-1. Merge [#19](https://github.com/terisuke/note_maker/issues/19) — editable draft and per-section regenerate on top of the streamed draft surface.
-2. Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) — SQLite history, so answer forks and draft versions survive restarts and section-regeneration candidates can become versioned draft records.
+1. Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) — SQLite history, so answer forks, source-derived guides, media-matrix briefs, draft versions, and evaluation records survive restarts.
+2. Phase D1 ([#29](https://github.com/terisuke/note_maker/issues/29)) in parallel with C1 — raise `workflow.go` handler coverage before more endpoint-heavy UI work lands.
+3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40), with runner implementation in [#57](https://github.com/terisuke/note_maker/issues/57)) — use `cmd/scenario/media_matrix` to run varied Note/Qiita/Zenn/Cor blog Evo X2 cases and record endpoint/model/elapsed/score/runes/verification. Full multi-case runs should happen after C1 unless the user explicitly wants one-off artifact files.
## Tracked issues
@@ -234,6 +235,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
- D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` (currently 0% 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)
## Consequences
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index 2229e7c..424c27a 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -1,6 +1,6 @@
# Issue and ADR guardrails
-Date: 2026-05-01 (last updated 2026-05-02)
+Date: 2026-05-01 (last updated 2026-05-03)
This document maps GitHub issues to [ADR 0001](../adrs/0001-three-phase-local-article-generation.md) and [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) and defines implementation guardrails.
@@ -21,7 +21,8 @@ Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/00
| [#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. |
-| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. |
+| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. It now owns live runs from `cmd/scenario/media_matrix` across note, Qiita, Zenn, and Cor blog. |
+| [#57](https://github.com/terisuke/note_maker/issues/57) | Live media-matrix runner and aggregate evaluator | ADR 0001/0002 runtime validation | Child of #40. Offline mode remains default; live mode must require explicit env vars and must refuse accidental workstation-local fallback for primary Evo X2 validation. |
Closed historical issues:
@@ -33,6 +34,10 @@ Closed historical issues:
| [#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. |
| [#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. |
+| [#24](https://github.com/terisuke/note_maker/issues/24) | Seed `terisuke` and `cloudia` personas | Persona seeds are available; third-persona work must wait for SQLite persistence. |
+| [#25](https://github.com/terisuke/note_maker/issues/25) | Persona/format question templates | Server templates exist; frontend must not duplicate template questions when sending custom questions. |
## ADR 0002 Phase Map
@@ -40,9 +45,9 @@ 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): introduces `internal/domain/persona` and `internal/domain/format`. The note.com host check moves out of application services into `internal/infrastructure/source/note` only.
+- 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 becomes import/export utility.
-- Phase D (Quality): handler tests are mandatory before any further endpoint additions land. Coverage gate: `internal/handlers/workflow.go` ≥ 80 %.
+- Phase D (Quality): handler tests are mandatory before any further endpoint-heavy UI work lands. Coverage gate: `internal/handlers/workflow.go` ≥ 80 %.
## Architectural Guardrails
@@ -63,7 +68,7 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) (
- SSH tunnels are allowed only as explicit developer diagnostics, not as the product default, because they depend on per-device SSH setup.
- Local llama.cpp (`http://127.0.0.1:8081/v1`) is fallback only. Do not set `LLM_BASE_URL` to local Ollama or local llama.cpp for Evo X2 validation unless the test is explicitly measuring fallback behavior.
- Runtime validation must report base URL, model, elapsed time, score, and draft length.
- - Each implementation PR that touches interview, prompt, draft, or runtime behavior should add one scenario datapoint with a deliberately varied medium/persona/format. Do not force every PR to rerun every scenario; build averages by collecting one different slice per phase.
+ - Each implementation PR that touches interview, prompt, draft, or runtime behavior should add one scenario datapoint with a deliberately varied medium/persona/format. Do not force every PR to rerun every scenario; build averages by collecting one different slice per phase. Use `cmd/scenario/media_matrix` as the canonical matrix for final Note/Qiita/Zenn/Cor blog comparison.
- Draft generation must run the lightweight final verification step before returning the final result; if verification reports NEEDS_REVIEW, surface the report instead of hiding it.
- If fallback validation fails the strict draft thresholds, keep Evo X2 primary enabled and track fallback hardening separately (Issue [#36](https://github.com/terisuke/note_maker/issues/36)).
- If Tailnet Evo X2 reaches the API but misses quality gates, track it under Issue [#40](https://github.com/terisuke/note_maker/issues/40), not as a transport regression.
@@ -141,6 +146,13 @@ Scenario tests and commands may require:
- `LLAMACPP_BASE_URL`
- `LLAMACPP_MODEL=gemma4:31b`
+Current live-media evaluation flow:
+
+1. `go run ./cmd/scenario/media_matrix` creates the deterministic cross-media brief/prompt matrix.
+2. `RUN_SOURCE_FETCH_SCENARIO=1 ... go run ./cmd/scenario/source_fetch` validates current live sources.
+3. #57 implements the resumable live runner and aggregate report.
+4. #40 owns the Evo X2 Tailnet live draft results that fill in elapsed seconds, score, verification, and rune counts for each media-matrix case.
+
## Completion Criteria
An issue can be closed only when:
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index c5d4749..f198454 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -26,14 +26,16 @@ The four phases below match ADR 0002. Each is independently shippable.
| C | Memory: SQLite + history UI | Persistence rewrite, extends [#14](https://github.com/terisuke/note_maker/issues/14) | [#26](https://github.com/terisuke/note_maker/issues/26), [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28) |
| D | Quality & coverage | Tests + thresholds | [#29](https://github.com/terisuke/note_maker/issues/29) (rolls up [#11](https://github.com/terisuke/note_maker/issues/11), [#13](https://github.com/terisuke/note_maker/issues/13)) |
-Recommended order: **A → C → B → D**. Phase B benefits from durable storage (Phase C) being in place first, otherwise the JSON store becomes a temporary obstacle for the persona registry.
+Original recommended order was **A → C → B → D**. The minimum Phase B work was pulled forward because realistic media-specific evaluation needed source fetchers, format validators, persona seeds, and server-side question templates. After the 2026-05-03 merges, the practical order is **C1 + D1 in parallel → C2/C3 → media-matrix Evo X2 evaluation under #40**.
-Current status after the 2026-05-02 merges:
+Current status after the 2026-05-03 merges:
- [#11](https://github.com/terisuke/note_maker/issues/11) strict Terisuke style tuning is closed.
- [#21](https://github.com/terisuke/note_maker/issues/21) B1 landed early: persona/format domain concepts, prompt dispatch, selectors, and validators exist.
- [#23](https://github.com/terisuke/note_maker/issues/23) is implemented for the in-repo generation surface: every registered format has a prompt fragment, embedded guide, validator, unit coverage, and deterministic sample validation.
- [#24](https://github.com/terisuke/note_maker/issues/24) is implemented for built-in seeds: `terisuke` and `cloudia` have distinct source bundles, default formats, prompt hints, and unit/scenario coverage. Live source-derived guide rebuilding remains dependent on [#22](https://github.com/terisuke/note_maker/issues/22).
+- [#22](https://github.com/terisuke/note_maker/issues/22) is implemented and revalidated for historical user/article sources. Cor blog style analysis should use GitHub Markdown for full bodies and RSS for discovery.
+- [#25](https://github.com/terisuke/note_maker/issues/25) is implemented: the server composes persona/format question templates, the UI fetches templates, and `cmd/scenario/media_matrix` defines varied cases for note, Cor blog, Zenn, Qiita, and homepage.
- [#38](https://github.com/terisuke/note_maker/issues/38) Tailnet OpenAI-compatible API is now the Evo X2 primary path. SSH tunnel access is diagnostic-only.
- [#36](https://github.com/terisuke/note_maker/issues/36) remains open for local llama.cpp fallback quality; it does not block Phase A work.
- [#40](https://github.com/terisuke/note_maker/issues/40) tracks primary Tailnet Evo X2 quality and runtime-metric stabilization.
@@ -47,7 +49,9 @@ Near-term implementation cut:
| 2 | [#17](https://github.com/terisuke/note_maker/issues/17) | The transcript can then use the streaming primitives instead of another spinner path. | Implemented and merged: answers render as editable bubbles and edits fork the in-memory session. |
| 3 | [#20](https://github.com/terisuke/note_maker/issues/20) | Deep-dive rationale belongs in the transcript once the transcript exists. | Implemented in code: every follow-up references the parent answer in prompt and UI, with validation recorded under `docs/validation/`. |
| 4 | [#19](https://github.com/terisuke/note_maker/issues/19) | Section regeneration is useful only after draft output can stream and be cancelled. | Markdown is editable, preview syncs, and section regeneration replaces only one subtree. |
-| 5 | [#26](https://github.com/terisuke/note_maker/issues/26) | Forked answers and draft versions need durable storage before broader persona library work. | SQLite stores sessions, answers, guides, articles, and draft versions. |
+| 5A | [#26](https://github.com/terisuke/note_maker/issues/26) | Forked answers, media-matrix briefs, draft versions, and evaluation results need durable storage before expensive Evo X2 runs become product memory. | SQLite stores sessions, answers, guides, articles, drafts, and import/export from the JSON store. |
+| 5B | [#29](https://github.com/terisuke/note_maker/issues/29) | #17-#25 added real handler surface; coverage should catch regressions before C2/C3 add more UI and endpoints. | `workflow.go` coverage reaches the agreed gate without real LLM/network. |
+| 6 | [#57](https://github.com/terisuke/note_maker/issues/57) feeding [#40](https://github.com/terisuke/note_maker/issues/40) | The final target is multi-media Evo X2 output evaluation, but repeated live runs should use the persisted context and media matrix. | Note/Qiita/Zenn/Cor blog runs record endpoint/model/elapsed/score/runes/verification in a comparable aggregate report. |
## Phase A — Conversation UX
@@ -325,4 +329,4 @@ Draft generation now includes a lightweight final verification pass before retur
## Immediate next implementation step
-Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#29](https://github.com/terisuke/note_maker/issues/29) are filed. [#18](https://github.com/terisuke/note_maker/issues/18), [#17](https://github.com/terisuke/note_maker/issues/17), and [#20](https://github.com/terisuke/note_maker/issues/20) are merged. [#19](https://github.com/terisuke/note_maker/issues/19) is implemented in code. [#23](https://github.com/terisuke/note_maker/issues/23) and [#24](https://github.com/terisuke/note_maker/issues/24) are implemented for the registry/prompt/validator/seed scope and validated in [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md). Continue with Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) so editable draft state and regeneration history survive restarts; source acquisition remains under [#22](https://github.com/terisuke/note_maker/issues/22).
+Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#25](https://github.com/terisuke/note_maker/issues/25) are implemented and merged. Continue with Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) and D1 ([#29](https://github.com/terisuke/note_maker/issues/29)) in parallel. Use [#57](https://github.com/terisuke/note_maker/issues/57) and [#40](https://github.com/terisuke/note_maker/issues/40) for the final Evo X2 media-matrix evaluation once persistence can retain expensive run outputs.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index e183d2a..219b48b 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -1,63 +1,89 @@
# Next implementation cut
-Date: 2026-05-02
+Date: 2026-05-03
-This document translates the current open issue set into the next executable implementation sequence after the Tailnet Evo X2 runtime fixes.
+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.
-## Current issue state
+## Current state
-Closed and incorporated:
+Implemented and merged:
- [#11](https://github.com/terisuke/note_maker/issues/11) — strict Terisuke style tuning.
-- [#18](https://github.com/terisuke/note_maker/issues/18) — SSE streaming and cancellation.
- [#17](https://github.com/terisuke/note_maker/issues/17) — chat transcript and editable answers.
-- [#20](https://github.com/terisuke/note_maker/issues/20) — deep-dive rationale in transcript.
+- [#18](https://github.com/terisuke/note_maker/issues/18) — SSE streaming, heartbeat, and cancellation.
+- [#19](https://github.com/terisuke/note_maker/issues/19) — editable draft Markdown and per-section regeneration.
+- [#20](https://github.com/terisuke/note_maker/issues/20) — deep-dive rationale in transcript and prompt.
- [#21](https://github.com/terisuke/note_maker/issues/21) — Persona and OutputFormat domain concepts.
-- [#38](https://github.com/terisuke/note_maker/issues/38) — Evo X2 Tailnet OpenAI-compatible API as primary runtime.
+- [#22](https://github.com/terisuke/note_maker/issues/22) — historical source acquisition for note, Zenn, Qiita, RSS, HTML, and GitHub Markdown.
+- [#23](https://github.com/terisuke/note_maker/issues/23) — format-specific prompt templates and validators.
+- [#24](https://github.com/terisuke/note_maker/issues/24) — built-in `terisuke` and `cloudia` persona seeds.
+- [#25](https://github.com/terisuke/note_maker/issues/25) — persona- and format-aware question templates plus the media matrix scenario.
+- [#38](https://github.com/terisuke/note_maker/issues/38) — Evo X2 Tailnet OpenAI-compatible API as the primary runtime.
Open and active:
-- Phase A: [#19](https://github.com/terisuke/note_maker/issues/19).
-- Phase B remaining: [#22](https://github.com/terisuke/note_maker/issues/22), [#23](https://github.com/terisuke/note_maker/issues/23), [#24](https://github.com/terisuke/note_maker/issues/24), [#25](https://github.com/terisuke/note_maker/issues/25).
-- Phase C: [#26](https://github.com/terisuke/note_maker/issues/26), [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28), extending [#14](https://github.com/terisuke/note_maker/issues/14).
-- Quality and packaging: [#13](https://github.com/terisuke/note_maker/issues/13), [#15](https://github.com/terisuke/note_maker/issues/15), [#29](https://github.com/terisuke/note_maker/issues/29), [#36](https://github.com/terisuke/note_maker/issues/36).
-- Runtime stabilization: [#40](https://github.com/terisuke/note_maker/issues/40) for primary Tailnet Evo X2 quality/metrics.
+- Memory/history: [#26](https://github.com/terisuke/note_maker/issues/26), [#14](https://github.com/terisuke/note_maker/issues/14).
+- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
+- Quality and coverage: [#29](https://github.com/terisuke/note_maker/issues/29), [#13](https://github.com/terisuke/note_maker/issues/13).
+- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
+- Live media-matrix runner: [#57](https://github.com/terisuke/note_maker/issues/57), child of [#40](https://github.com/terisuke/note_maker/issues/40).
+- 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).
-## Next target
+## Final evaluation target
-The ordering correction is now applied: [#18](https://github.com/terisuke/note_maker/issues/18), [#17](https://github.com/terisuke/note_maker/issues/17), and [#20](https://github.com/terisuke/note_maker/issues/20) have landed. [#19](https://github.com/terisuke/note_maker/issues/19) is the final Phase A UX cut before Phase C persistence.
+The final integrated evaluation should use `cmd/scenario/media_matrix` as the input matrix, then run live Evo X2 Tailnet draft scenarios for:
-Reason: a real Tailnet Evo X2 scenario reached the correct endpoint but took `1396.80s` and still missed quality gates. A spinner-only UI is not usable at that latency. Streaming, heartbeat, cancellation, and partial-result retention are the highest-leverage improvement before reshaping the transcript.
+| Case | Medium | Style | Primary source |
+|---|---|---|---|
+| `terisuke_note_essay` | note | reflective essay | `note:cor_instrument` |
+| `cor_blog_technical_report` | Cor.inc blog | technical report | `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cor_blog_vision_sharing` | Cor.inc blog | vision sharing | `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cloudia_zenn_tutorial` | Zenn | tutorial | `zenn:cloudia` |
+| `cloudia_qiita_how_to` | Qiita | practical how-to | `qiita:Cloudia_Cor_Inc` |
-## Implementation sequence
+The homepage section case remains in the matrix as a useful format check, but the user-facing publishing targets for the full Evo X2 run are note, Qiita, Zenn, and the company blog.
-1. **[#18] SSE streaming and cancellation**
- - Add streaming to `internal/infrastructure/llamacpp`.
- - Add status, heartbeat, token, done, and error event types.
- - Support cancellation from browser disconnect and explicit Cancel.
- - Keep non-streaming generation for tests and compatibility.
- - Status: implemented and merged.
+Each live run must record:
-2. **[#17] Chat transcript and editable answers**
- - Replace the bounded log with a transcript surface.
- - Render existing fixed and deep-dive answers as bubbles.
- - Add in-memory fork-on-edit endpoint first; persistence follows in Phase C.
- - Status: implemented and merged.
+- endpoint and whether it was primary or fallback,
+- model per phase,
+- elapsed seconds,
+- generated runes,
+- style score and failed metrics,
+- final verification result,
+- output path and scenario case id.
-3. **[#20] Deep-dive rationale in transcript**
- - Add parent-answer excerpts to prompt and UI.
- - Ensure LLM and rule-based fallback paths produce the same contextual prefix.
- - Status: implemented and merged.
+## Before the full Evo X2 media run
-4. **[#19] Editable draft and section regenerate**
- - Make the Markdown draft editable after streaming lands.
- - Add section anchor parsing and replacement tests.
- - Regenerate one heading subtree at a time.
- - Status: implemented in code; merge before moving to #26.
+There are three prerequisites before running the full multi-medium Evo X2 evaluation:
-5. **[#26] SQLite persistence**
- - Persist answer forks, draft versions, guide versions, and project/article history.
+1. **Persistence first**: #26 must land so media-matrix drafts, evaluations, regenerated sections, answer forks, and style guides can be saved and reopened. Repeated Evo X2 runs are too expensive to leave only as loose files.
+2. **Handler coverage gate**: #29 should run in parallel with #26 and must close before more endpoint-heavy UI work. #17-#25 added real handler surface; the next phase should harden it instead of adding more unguarded routes.
+3. **Scenario ownership**: #40 owns the live Evo X2 media-matrix quality target. [#57](https://github.com/terisuke/note_maker/issues/57) owns the reusable runner/aggregate evaluator that executes the matrix and writes comparable reports.
-## Additional gap
+## Parallel implementation plan
-[#40](https://github.com/terisuke/note_maker/issues/40) tracks primary-runtime quality and runtime metrics. That work is separate from [#36](https://github.com/terisuke/note_maker/issues/36), which is only for local llama.cpp fallback quality. Do not block [#18](https://github.com/terisuke/note_maker/issues/18) on #40; streaming is needed precisely because these long primary-runtime runs can fail late and still need to preserve partial output.
+Use subagents with disjoint write scopes:
+
+| Lane | Issue | Subagent role | Write scope | Done when |
+|---|---|---|---|---|
+| A | [#26](https://github.com/terisuke/note_maker/issues/26) / [#14](https://github.com/terisuke/note_maker/issues/14) | SQLite worker | `internal/infrastructure/repository/sqlite`, repository interfaces, boot wiring, migrations | JSON store imports, sessions/guides/briefs/drafts persist, cross-persona tests pass |
+| B | [#29](https://github.com/terisuke/note_maker/issues/29) | Handler coverage worker | `internal/handlers/*_test.go`, coverage script/docs | `workflow.go` reaches the agreed coverage gate without real LLM/network |
+| C | [#57](https://github.com/terisuke/note_maker/issues/57), feeding [#40](https://github.com/terisuke/note_maker/issues/40) | Scenario metrics worker | `cmd/scenario/*`, `docs/validation/*`, Make targets | media-matrix live runner records endpoint/model/elapsed/score/runes/verification in aggregate JSON/Markdown |
+
+Lane A and Lane B can run immediately in parallel. Lane C can start by implementing offline/resumable runner mechanics now, but the full multi-case Evo X2 run should wait until Lane A provides persistence or until the user explicitly wants a one-off artifact-file run.
+
+## Recommended order
+
+1. Merge this docs alignment PR.
+2. Start #26 and #29 in parallel.
+3. Merge #29 as soon as handler coverage is sufficient.
+4. Merge #26 once JSON import, SQLite schema, and restart recovery are proven.
+5. Use #57/#40 to run one media-matrix case per implementation phase, then run the full note/Qiita/Zenn/company-blog pass after the persistence layer is stable.
+6. Start #27 and #28 after #26; both depend on persistent projects/sessions/guides.
+7. Start #13 after #27/#28 have enough browser surface to justify E2E tests.
+8. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
+
+## Why not run the full Evo X2 matrix now?
+
+The source and prompt matrix is ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. Running all media cases before persistence would produce useful files but not durable product memory. The better sequence is to make the system capable of storing those expensive results, then use #40 to evaluate one varied slice per phase and finally run the full comparison table.
From 5c39763514fd8f0ec73907f9560440cd2d335bce Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 00:48:30 +0900
Subject: [PATCH 18/33] feat: add sqlite persistence and media matrix runner
---
Makefile | 6 +-
README.md | 4 +-
cmd/scenario/live_media_matrix/main.go | 347 +++++++
...02-multi-persona-multi-format-extension.md | 17 +-
.../issue-adr-guardrails.md | 10 +-
.../multi-persona-multi-format.md | 28 +-
.../next-implementation-cut.md | 39 +-
...matrix-integrated-evaluation-2026-05-03.md | 21 +-
go.mod | 1 +
go.sum | 2 +
go.work.sum | 2 +
internal/handlers/generate_test.go | 20 +
internal/handlers/handlers_test.go | 45 +
internal/handlers/workflow.go | 25 +-
internal/handlers/workflow_edit_test.go | 53 +
internal/handlers/workflow_handler_test.go | 273 +++++
internal/handlers/workflow_mode_test.go | 57 ++
.../workflow_regenerate_section_test.go | 56 ++
internal/handlers/workflow_store_test.go | 20 +
internal/handlers/workflow_stream_test.go | 137 +++
.../sqlite/migrations/0001_workflow.sql | 162 +++
.../repository/sqlite/workflow.go | 950 ++++++++++++++++++
.../repository/sqlite/workflow_test.go | 299 ++++++
23 files changed, 2530 insertions(+), 44 deletions(-)
create mode 100644 cmd/scenario/live_media_matrix/main.go
create mode 100644 internal/handlers/handlers_test.go
create mode 100644 internal/handlers/workflow_store_test.go
create mode 100644 internal/infrastructure/repository/sqlite/migrations/0001_workflow.sql
create mode 100644 internal/infrastructure/repository/sqlite/workflow.go
create mode 100644 internal/infrastructure/repository/sqlite/workflow_test.go
diff --git a/Makefile b/Makefile
index d088c79..400c225 100644
--- a/Makefile
+++ b/Makefile
@@ -38,7 +38,7 @@ LLAMACPP_HF_REPO ?= ggml-org/gemma-4-31B-it-GGUF
LLAMACPP_HF_FILE ?= gemma-4-31B-it-Q4_K_M.gguf
LLAMA_SERVER ?= llama-server
-.PHONY: app dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models scenario-evo-x2 server llama check
+.PHONY: app dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models scenario-evo-x2 scenario-media-matrix-live server llama check
app: dev
@@ -63,6 +63,10 @@ evo-x2-ssh-models:
scenario-evo-x2: evo-x2-preflight
RUN_NOTE_SCENARIO=1 RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_STREAM_DRAFT=1 LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=2800 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/full_workflow
+scenario-media-matrix-live: evo-x2-preflight
+ SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
+ RUN_LIVE_MEDIA_MATRIX=1 SCENARIO_STREAM_DRAFT=1 SCENARIO_OUTPUT_DIR=tmp/media_matrix LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=1400 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/live_media_matrix
+
server:
go run ./cmd/server
diff --git a/README.md b/README.md
index 0e7e4f0..2580501 100644
--- a/README.md
+++ b/README.md
@@ -83,10 +83,12 @@ mise run evo-x2
既定では Tailnet 上の `http://evo-x2.tailb30e58.ts.net/v1` に接続します。モデルを変える場合は `.env.evo-x2.example` を参考に `LLM_MODEL`、`STYLE_LLM_MODEL`、`BRIEF_LLM_MODEL`、`ARTICLE_LLM_MODEL`、`DRAFT_LLM_MODEL`、`VERIFY_LLM_MODEL` を設定してください。120B級のモデルを使う場合は `LLM_TIMEOUT_SECONDS` を長めに設定します。
-画面上部の「設定」から、フェーズ別に使うモデルと一問一答の質問を変更できます。質問は初期テンプレートを編集でき、追加質問も下書き生成のブリーフに含まれます。
+画面上部の「設定」から、フェーズ別に使うモデルと一問一答の質問を変更できます。質問は選択したペルソナと出力形式に応じた固定テンプレートが表示され、追加質問も下書き生成のブリーフに含まれます。
文体分析結果、取材セッションの回答、完成ブリーフは `WORKFLOW_STORE_PATH` にJSONとして永続化されます。既定値は `data/workflow_store.json` です。
+SQLiteを試す場合は `WORKFLOW_STORE_DRIVER=sqlite` を指定します。既定パスは `data/workflow_store.db` で、`WORKFLOW_STORE_PATH` で変更できます。JSON store は互換性のため既定のまま残しています。
+
フェーズ別モデルの目安:
- `STYLE_LLM_MODEL`: Note記事取得後の文体ガイド整理用。既定は軽量な `gemma4:e2b`。
diff --git a/cmd/scenario/live_media_matrix/main.go b/cmd/scenario/live_media_matrix/main.go
new file mode 100644
index 0000000..a5c5042
--- /dev/null
+++ b/cmd/scenario/live_media_matrix/main.go
@@ -0,0 +1,347 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ defaultMatrixDir = "tmp/media_matrix"
+ defaultLiveDir = "tmp/media_matrix/live"
+)
+
+type matrixOutput struct {
+ Cases []matrixCase `json:"cases"`
+}
+
+type matrixCase struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ Theme string `json:"theme"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ SourceSelectors []string `json:"source_selectors"`
+ BriefPath string `json:"brief_path"`
+ PromptPath string `json:"prompt_path"`
+}
+
+type aggregateReport struct {
+ GeneratedBy string `json:"generated_by"`
+ Live bool `json:"live"`
+ MatrixPath string `json:"matrix_path"`
+ GeneratedAt string `json:"generated_at"`
+ Rows []resultRow `json:"rows"`
+}
+
+type resultRow struct {
+ CaseID string `json:"case_id"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Theme string `json:"theme"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ SourceSelectors []string `json:"source_selectors"`
+ Status string `json:"status"`
+ ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
+ FirstChunkMS int `json:"first_chunk_ms,omitempty"`
+ Chunks int `json:"chunks,omitempty"`
+ Score float64 `json:"score,omitempty"`
+ Runes int `json:"runes,omitempty"`
+ Passed bool `json:"passed,omitempty"`
+ ScenarioPassed bool `json:"scenario_passed,omitempty"`
+ VerificationPerformed bool `json:"verification_performed,omitempty"`
+ VerificationPassed bool `json:"verification_passed,omitempty"`
+ LLMBaseURL string `json:"llm_base_url,omitempty"`
+ LLMModel string `json:"llm_model,omitempty"`
+ VerifyModel string `json:"verify_model,omitempty"`
+ OutputDir string `json:"output_dir"`
+ DraftPath string `json:"draft_path,omitempty"`
+ EvaluationPath string `json:"evaluation_path,omitempty"`
+ VerificationPath string `json:"verification_path,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func main() {
+ matrixDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultMatrixDir)
+ matrixPath := filepath.Join(matrixDir, "matrix.json")
+ liveDir := envOrDefault("LIVE_MEDIA_MATRIX_OUTPUT_DIR", defaultLiveDir)
+ if err := os.MkdirAll(liveDir, 0o755); err != nil {
+ fatalf("create live output dir: %v", err)
+ }
+
+ matrix := readMatrix(matrixPath)
+ live := os.Getenv("RUN_LIVE_MEDIA_MATRIX") == "1"
+ selected := selectedCaseIDs(os.Getenv("LIVE_MEDIA_MATRIX_CASES"))
+ rows := make([]resultRow, 0, len(matrix.Cases))
+ for _, item := range matrix.Cases {
+ if len(selected) > 0 && !selected[item.ID] {
+ continue
+ }
+ if !live {
+ rows = append(rows, plannedRow(item, filepath.Join(liveDir, item.ID)))
+ continue
+ }
+ rows = append(rows, runCase(item, filepath.Join(liveDir, item.ID)))
+ }
+ if len(rows) == 0 {
+ fatalf("no media matrix cases selected")
+ }
+
+ report := aggregateReport{
+ GeneratedBy: "cmd/scenario/live_media_matrix",
+ Live: live,
+ MatrixPath: matrixPath,
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Rows: rows,
+ }
+ writeJSON(filepath.Join(liveDir, "aggregate.json"), report)
+ writeFile(filepath.Join(liveDir, "aggregate.md"), markdownReport(report))
+
+ fmt.Printf("live media matrix completed\n")
+ fmt.Printf("live=%v\n", live)
+ fmt.Printf("cases=%d\n", len(rows))
+ fmt.Printf("aggregate=%s\n", filepath.Join(liveDir, "aggregate.json"))
+ fmt.Printf("report=%s\n", filepath.Join(liveDir, "aggregate.md"))
+ if live && hasFailure(rows) {
+ os.Exit(1)
+ }
+}
+
+func readMatrix(path string) matrixOutput {
+ encoded, err := os.ReadFile(path)
+ if err != nil {
+ fatalf("read %s: %v", path, err)
+ }
+ var matrix matrixOutput
+ if err := json.Unmarshal(encoded, &matrix); err != nil {
+ fatalf("decode %s: %v", path, err)
+ }
+ if len(matrix.Cases) == 0 {
+ fatalf("%s has no cases", path)
+ }
+ return matrix
+}
+
+func plannedRow(item matrixCase, outputDir string) resultRow {
+ return resultRow{
+ CaseID: item.ID,
+ Medium: item.Medium,
+ Style: item.Style,
+ PersonaID: item.PersonaID,
+ OutputFormatID: item.OutputFormatID,
+ Theme: item.Theme,
+ TargetLengthStructure: item.TargetLengthStructure,
+ SourceSelectors: append([]string(nil), item.SourceSelectors...),
+ Status: "planned",
+ OutputDir: outputDir,
+ }
+}
+
+func runCase(item matrixCase, outputDir string) resultRow {
+ row := plannedRow(item, outputDir)
+ row.Status = "running"
+ if envBool("LIVE_MEDIA_MATRIX_RESUME") && fileExists(filepath.Join(outputDir, "draft.md")) {
+ row.Status = "skipped_existing"
+ row.DraftPath = filepath.Join(outputDir, "draft.md")
+ row.EvaluationPath = filepath.Join(outputDir, "evaluation.json")
+ row.VerificationPath = filepath.Join(outputDir, "verification.json")
+ return row
+ }
+ if err := os.MkdirAll(outputDir, 0o755); err != nil {
+ row.Status = "failed"
+ row.Error = err.Error()
+ return row
+ }
+
+ cmd := exec.Command("go", "run", "./cmd/scenario/draft_generation")
+ cmd.Env = append(os.Environ(),
+ "RUN_LOCAL_LLM_SCENARIO=1",
+ "ARTICLE_BRIEF_PATH="+item.BriefPath,
+ "SCENARIO_OUTPUT_DIR="+outputDir,
+ )
+ if os.Getenv("SCENARIO_STREAM_DRAFT") == "" {
+ cmd.Env = append(cmd.Env, "SCENARIO_STREAM_DRAFT=1")
+ }
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ err := cmd.Run()
+ writeFile(filepath.Join(outputDir, "stdout.txt"), stdout.String())
+ writeFile(filepath.Join(outputDir, "stderr.txt"), stderr.String())
+
+ values := parseKeyValues(stdout.String())
+ row.ElapsedSeconds = floatValue(values["elapsed_seconds"])
+ row.FirstChunkMS = intValue(values["first_chunk_ms"])
+ row.Chunks = intValue(values["chunks"])
+ row.Score = floatValue(values["score"])
+ row.Runes = intValue(values["runes"])
+ row.Passed = boolValue(values["passed"])
+ row.ScenarioPassed = boolValue(values["scenario_passed"])
+ row.VerificationPerformed = boolValue(values["verification_performed"])
+ row.VerificationPassed = boolValue(values["verification_passed"])
+ row.LLMBaseURL = values["llm_base_url"]
+ row.LLMModel = values["llm_model"]
+ row.VerifyModel = values["verify_model"]
+ row.DraftPath = values["draft"]
+ row.EvaluationPath = values["evaluation"]
+ row.VerificationPath = values["verification"]
+ if err != nil {
+ row.Status = "failed"
+ row.Error = strings.TrimSpace(stderr.String())
+ if row.Error == "" {
+ row.Error = err.Error()
+ }
+ return row
+ }
+ row.Status = "passed"
+ return row
+}
+
+func parseKeyValues(output string) map[string]string {
+ values := map[string]string{}
+ for _, line := range strings.Split(output, "\n") {
+ key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
+ if !ok {
+ continue
+ }
+ values[strings.TrimSpace(key)] = strings.TrimSpace(value)
+ }
+ return values
+}
+
+func markdownReport(report aggregateReport) string {
+ var builder strings.Builder
+ builder.WriteString("# Live media matrix aggregate\n\n")
+ builder.WriteString(fmt.Sprintf("- Generated at: `%s`\n", report.GeneratedAt))
+ builder.WriteString(fmt.Sprintf("- Live mode: `%v`\n", report.Live))
+ builder.WriteString(fmt.Sprintf("- Matrix: `%s`\n\n", report.MatrixPath))
+ builder.WriteString("| Case | Medium | Style | Status | Seconds | Score | Runes | Verification | Output |\n")
+ builder.WriteString("|---|---|---|---|---:|---:|---:|---|---|\n")
+ for _, row := range report.Rows {
+ builder.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %.2f | %.1f | %d | %v | `%s` |\n",
+ row.CaseID,
+ escapePipes(row.Medium),
+ escapePipes(row.Style),
+ row.Status,
+ row.ElapsedSeconds,
+ row.Score,
+ row.Runes,
+ row.VerificationPassed,
+ row.OutputDir,
+ ))
+ }
+ failures := failedRows(report.Rows)
+ if len(failures) > 0 {
+ builder.WriteString("\n## Failures\n\n")
+ for _, row := range failures {
+ builder.WriteString(fmt.Sprintf("- `%s`: %s\n", row.CaseID, strings.TrimSpace(row.Error)))
+ }
+ }
+ return builder.String()
+}
+
+func failedRows(rows []resultRow) []resultRow {
+ failures := make([]resultRow, 0)
+ for _, row := range rows {
+ if row.Status == "failed" || (row.Status == "passed" && !row.ScenarioPassed) {
+ failures = append(failures, row)
+ }
+ }
+ return failures
+}
+
+func hasFailure(rows []resultRow) bool {
+ return len(failedRows(rows)) > 0
+}
+
+func selectedCaseIDs(value string) map[string]bool {
+ parts := splitCSV(value)
+ if len(parts) == 0 {
+ return nil
+ }
+ selected := make(map[string]bool, len(parts))
+ for _, part := range parts {
+ selected[part] = true
+ }
+ return selected
+}
+
+func splitCSV(value string) []string {
+ if strings.TrimSpace(value) == "" {
+ return nil
+ }
+ parts := strings.Split(value, ",")
+ values := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if cleaned := strings.TrimSpace(part); cleaned != "" {
+ values = append(values, cleaned)
+ }
+ }
+ sort.Strings(values)
+ return values
+}
+
+func boolValue(value string) bool {
+ parsed, _ := strconv.ParseBool(value)
+ return parsed
+}
+
+func intValue(value string) int {
+ parsed, _ := strconv.Atoi(value)
+ return parsed
+}
+
+func floatValue(value string) float64 {
+ parsed, _ := strconv.ParseFloat(value, 64)
+ return parsed
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func envBool(key string) bool {
+ return os.Getenv(key) == "1" || strings.EqualFold(os.Getenv(key), "true")
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func writeJSON(path string, value any) {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatalf("encode %s: %v", path, err)
+ }
+ writeFile(path, string(encoded)+"\n")
+}
+
+func writeFile(path, content string) {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ fatalf("write %s: %v", path, err)
+ }
+}
+
+func escapePipes(value string) string {
+ return strings.ReplaceAll(value, "|", "\\|")
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 5e0b4c8..7cb8666 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -195,7 +195,7 @@ The full work is broken into four phases tracked by issues. Each phase is indepe
- Handler test coverage, Issue [#11](https://github.com/terisuke/note_maker/issues/11) (style threshold), Issue [#13](https://github.com/terisuke/note_maker/issues/13) (Playwright), Issue [#15](https://github.com/terisuke/note_maker/issues/15) (desktop packaging) follow-up.
- Issue: [#29](https://github.com/terisuke/note_maker/issues/29).
-Original recommended order was A → C → B → D. Implementation intentionally pulled the minimum B work forward because source acquisition, format validation, persona seeds, and question templates were required before a realistic cross-media evaluation could be defined. With Phases A and B now implemented, the next order is C1 + D1 in parallel, then C2/C3, then the full Evo X2 media-matrix evaluation under #40.
+Original recommended order was A → C → B → D. Implementation intentionally pulled the minimum B work forward because source acquisition, format validation, persona seeds, and question templates were required before a realistic cross-media evaluation could be defined. With Phases A and B now implemented, the 2026-05-03 implementation cut lands C1, D1, and the #57 runner foundation in parallel. The next order is C2/C3, one bounded Evo X2 runner validation, then the full Evo X2 media-matrix evaluation under #40.
Current implementation status as of 2026-05-03:
@@ -211,12 +211,15 @@ Current implementation status as of 2026-05-03:
- Phase A3 is implemented in code: the generated Markdown textarea is editable, preview rendering live-syncs through `marked`, both preview and Markdown tabs can copy content, and `POST /api/drafts/{id}/regenerate-section` rewrites exactly one `## ` subtree while preserving the rest of the draft byte-for-byte ([#19](https://github.com/terisuke/note_maker/issues/19)). Validation is recorded in [Issue 19 section regeneration validation](../validation/issue-19-section-regeneration-2026-05-02.md).
- 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 in the current cut: `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 web app can opt in with `WORKFLOW_STORE_DRIVER=sqlite`; the JSON store remains the default compatibility path ([#26](https://github.com/terisuke/note_maker/issues/26)).
+- Phase D1 is implemented in the current cut: 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.0% statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)).
+- Runtime runner support is implemented in the current cut: `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)).
Near-term execution order:
-1. Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) — SQLite history, so answer forks, source-derived guides, media-matrix briefs, draft versions, and evaluation records survive restarts.
-2. Phase D1 ([#29](https://github.com/terisuke/note_maker/issues/29)) in parallel with C1 — raise `workflow.go` handler coverage before more endpoint-heavy UI work lands.
-3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40), with runner implementation in [#57](https://github.com/terisuke/note_maker/issues/57)) — use `cmd/scenario/media_matrix` to run varied Note/Qiita/Zenn/Cor blog Evo X2 cases and record endpoint/model/elapsed/score/runes/verification. Full multi-case runs should happen after C1 unless the user explicitly wants one-off artifact files.
+1. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
+2. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
+3. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
## Tracked issues
@@ -231,11 +234,11 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- B3 — [#23](https://github.com/terisuke/note_maker/issues/23) Format-specific prompt templates and draft validators (note / markdown_blog / zenn / qiita / homepage_section). Implemented: validators, embedded format guides, prompt injection, and deterministic scenario samples exist.
- 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))
+- C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store.
- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
-- D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` (currently 0% 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)
+- 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.
## Consequences
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index 424c27a..fa22553 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -24,6 +24,12 @@ Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/00
| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. It now owns live runs from `cmd/scenario/media_matrix` across note, Qiita, Zenn, and Cor blog. |
| [#57](https://github.com/terisuke/note_maker/issues/57) | Live media-matrix runner and aggregate evaluator | ADR 0001/0002 runtime validation | Child of #40. Offline mode remains default; live mode must require explicit env vars and must refuse accidental workstation-local fallback for primary Evo X2 validation. |
+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.
+- [#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.
+
Closed historical issues:
| Issue | Completed Scope | Relation to ADR 0001 |
@@ -46,7 +52,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 becomes import/export utility.
+- Phase C (SQLite store): repository interfaces stay; only implementations change. JSON-file store remains the default compatibility path until the #14 import/export follow-up is explicit.
- Phase D (Quality): handler tests are mandatory before any further endpoint-heavy UI work lands. Coverage gate: `internal/handlers/workflow.go` ≥ 80 %.
## Architectural Guardrails
@@ -150,7 +156,7 @@ Current live-media evaluation flow:
1. `go run ./cmd/scenario/media_matrix` creates the deterministic cross-media brief/prompt matrix.
2. `RUN_SOURCE_FETCH_SCENARIO=1 ... go run ./cmd/scenario/source_fetch` validates current live sources.
-3. #57 implements the resumable live runner and aggregate report.
+3. #57's runner is available; run one bounded live case first and attach the aggregate output to #40.
4. #40 owns the Evo X2 Tailnet live draft results that fill in elapsed seconds, score, verification, and rune counts for each media-matrix case.
## Completion Criteria
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index f198454..33c5b1d 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -26,7 +26,7 @@ The four phases below match ADR 0002. Each is independently shippable.
| C | Memory: SQLite + history UI | Persistence rewrite, extends [#14](https://github.com/terisuke/note_maker/issues/14) | [#26](https://github.com/terisuke/note_maker/issues/26), [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28) |
| D | Quality & coverage | Tests + thresholds | [#29](https://github.com/terisuke/note_maker/issues/29) (rolls up [#11](https://github.com/terisuke/note_maker/issues/11), [#13](https://github.com/terisuke/note_maker/issues/13)) |
-Original recommended order was **A → C → B → D**. The minimum Phase B work was pulled forward because realistic media-specific evaluation needed source fetchers, format validators, persona seeds, and server-side question templates. After the 2026-05-03 merges, the practical order is **C1 + D1 in parallel → C2/C3 → media-matrix Evo X2 evaluation under #40**.
+Original recommended order was **A → C → B → D**. The minimum Phase B work was pulled forward because realistic media-specific evaluation needed source fetchers, format validators, persona seeds, and server-side question templates. The 2026-05-03 implementation cut lands **C1 + D1 + the #57 runner foundation** in parallel. The practical order is now **C2/C3 → one bounded Evo X2 runner validation → full media-matrix Evo X2 evaluation under #40**.
Current status after the 2026-05-03 merges:
@@ -49,9 +49,9 @@ Near-term implementation cut:
| 2 | [#17](https://github.com/terisuke/note_maker/issues/17) | The transcript can then use the streaming primitives instead of another spinner path. | Implemented and merged: answers render as editable bubbles and edits fork the in-memory session. |
| 3 | [#20](https://github.com/terisuke/note_maker/issues/20) | Deep-dive rationale belongs in the transcript once the transcript exists. | Implemented in code: every follow-up references the parent answer in prompt and UI, with validation recorded under `docs/validation/`. |
| 4 | [#19](https://github.com/terisuke/note_maker/issues/19) | Section regeneration is useful only after draft output can stream and be cancelled. | Markdown is editable, preview syncs, and section regeneration replaces only one subtree. |
-| 5A | [#26](https://github.com/terisuke/note_maker/issues/26) | Forked answers, media-matrix briefs, draft versions, and evaluation results need durable storage before expensive Evo X2 runs become product memory. | SQLite stores sessions, answers, guides, articles, drafts, and import/export from the JSON store. |
-| 5B | [#29](https://github.com/terisuke/note_maker/issues/29) | #17-#25 added real handler surface; coverage should catch regressions before C2/C3 add more UI and endpoints. | `workflow.go` coverage reaches the agreed gate without real LLM/network. |
-| 6 | [#57](https://github.com/terisuke/note_maker/issues/57) feeding [#40](https://github.com/terisuke/note_maker/issues/40) | The final target is multi-media Evo X2 output evaluation, but repeated live runs should use the persisted context and media matrix. | Note/Qiita/Zenn/Cor blog runs record endpoint/model/elapsed/score/runes/verification in a comparable aggregate report. |
+| 5A | [#26](https://github.com/terisuke/note_maker/issues/26) | Forked answers, media-matrix briefs, draft versions, and evaluation results need durable storage before expensive Evo X2 runs become product memory. | Implemented in the current cut: SQLite stores sessions, answers, guides, articles, drafts, source snapshots, verification, and section-regeneration versions; web-app opt-in is `WORKFLOW_STORE_DRIVER=sqlite`. |
+| 5B | [#29](https://github.com/terisuke/note_maker/issues/29) | #17-#25 added real handler surface; coverage should catch regressions before C2/C3 add more UI and endpoints. | Implemented in the current cut: `go test ./internal/handlers -cover` reports 80.0% without real LLM/network. |
+| 6 | [#57](https://github.com/terisuke/note_maker/issues/57) feeding [#40](https://github.com/terisuke/note_maker/issues/40) | The final target is multi-media Evo X2 output evaluation, but repeated live runs should use the persisted context and media matrix. | Implemented in the current cut: planned aggregate mode is offline by default; live mode records endpoint/model/elapsed/score/runes/verification in JSON/Markdown. |
## Phase A — Conversation UX
@@ -124,7 +124,7 @@ New packages:
- `internal/domain/persona`
- types: `Persona`, `PersonaID`, `PersonaSeed`
- - registry: in-memory + SQLite-backed once Phase C lands
+ - registry: in-memory + opt-in SQLite-backed workflow store after Phase C1
- `internal/domain/format`
- types: `OutputFormat`, `FormatID`, `Validator`
- registry: same dual-mode
@@ -260,17 +260,19 @@ Implementation note as of 2026-05-03: #25 is implemented with `brief.ComposeFixe
### C1 — SQLite store (extends Issue [#14](https://github.com/terisuke/note_maker/issues/14))
-- New package `internal/infrastructure/repository/sqlite` using `modernc.org/sqlite` (pure Go, no CGO) or `mattn/go-sqlite3` if CGO is acceptable.
+Status: implemented in the current cut as an opt-in workflow store. C2/C3 still need UI/read APIs on top of the schema.
+
+- New package `internal/infrastructure/repository/sqlite` using `mattn/go-sqlite3`.
- Schema migrations under `internal/infrastructure/repository/sqlite/migrations/` numbered `0001_*.sql`, applied at boot via a tiny in-process migrator.
- Tables (minimum): `personas`, `author_sources`, `writing_style_guides` (versioned), `projects`, `articles`, `brief_sessions`, `brief_answers` (with `parent_answer_id`), `drafts` (versioned).
-- The existing JSON file becomes an export/import utility. On first boot, if the JSON file exists, it is imported.
-- Default DB path: `data/note_maker.db` (gitignored).
+- The existing JSON file remains the default compatibility store for now. Import/export between JSON and SQLite stays under the broader [#14](https://github.com/terisuke/note_maker/issues/14) umbrella.
+- Default SQLite DB path: `data/workflow_store.db` when `WORKFLOW_STORE_DRIVER=sqlite` is set.
Acceptance:
-- All repository interfaces have SQLite implementations. Existing in-memory implementations remain for tests.
-- `go test ./...` passes against both implementations.
-- Re-opening the app after a restart shows past projects, sessions, and drafts.
+- The current workflow store methods have SQLite implementations. Existing in-memory and JSON-file implementations remain for tests and compatibility.
+- `go test ./...` passes, and focused SQLite restart tests prove sessions, briefs, source snapshots, draft versions, and section-regeneration records survive reopening.
+- Re-opening the app after a restart can use SQLite when `WORKFLOW_STORE_DRIVER=sqlite`; browser-visible history still lands in C2/C3.
### C2 — Persona / past-session picker UI
@@ -299,7 +301,7 @@ Acceptance:
### D1 — Handler tests
-`internal/handlers/workflow.go` is 467 lines and currently has zero direct test coverage. This is the highest-leverage gap because every Phase A / B / C change touches it.
+`internal/handlers/workflow.go` is now 1,000+ lines and has focused direct tests. This remains the highest-leverage gap because every Phase A / B / C change touches it.
- Add `internal/handlers/workflow_test.go` with table-driven tests for each handler: `AnalyzeAuthorStyleHandler`, `CreateBriefSessionHandler`, `AnswerBriefSessionHandler`, `GenerateDraftHandler`, plus the new endpoints introduced by Phases A and B.
- Use injected fakes for the application services (the existing pattern from `generate_test.go`).
@@ -329,4 +331,4 @@ Draft generation now includes a lightweight final verification pass before retur
## Immediate next implementation step
-Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#25](https://github.com/terisuke/note_maker/issues/25) are implemented and merged. Continue with Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26), extending [#14](https://github.com/terisuke/note_maker/issues/14)) and D1 ([#29](https://github.com/terisuke/note_maker/issues/29)) in parallel. Use [#57](https://github.com/terisuke/note_maker/issues/57) and [#40](https://github.com/terisuke/note_maker/issues/40) for the final Evo X2 media-matrix evaluation once persistence can retain expensive run outputs.
+Issues [#17](https://github.com/terisuke/note_maker/issues/17)–[#25](https://github.com/terisuke/note_maker/issues/25) are implemented and merged. The current cut implements Phase C1 ([#26](https://github.com/terisuke/note_maker/issues/26)), D1 ([#29](https://github.com/terisuke/note_maker/issues/29)), and the media-matrix runner ([#57](https://github.com/terisuke/note_maker/issues/57)). After this lands, start Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and run one bounded Evo X2 case through [#40](https://github.com/terisuke/note_maker/issues/40) before the full note/Qiita/Zenn/company-blog pass.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 219b48b..f88c37b 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -20,13 +20,18 @@ Implemented and merged:
- [#25](https://github.com/terisuke/note_maker/issues/25) — persona- and format-aware question templates plus the media matrix scenario.
- [#38](https://github.com/terisuke/note_maker/issues/38) — Evo X2 Tailnet OpenAI-compatible API as the primary runtime.
+Implemented in the current cut:
+
+- [#26](https://github.com/terisuke/note_maker/issues/26) — SQLite-backed workflow store with project/article/session/draft/source snapshot schema and explicit opt-in web-app wiring via `WORKFLOW_STORE_DRIVER=sqlite`.
+- [#29](https://github.com/terisuke/note_maker/issues/29) — focused handler tests for the expanded `workflow.go` surface; `go test ./internal/handlers -cover` now reaches 80.0%.
+- [#57](https://github.com/terisuke/note_maker/issues/57) — live media-matrix runner and aggregate JSON/Markdown evaluator with offline planned mode by default.
+
Open and active:
-- Memory/history: [#26](https://github.com/terisuke/note_maker/issues/26), [#14](https://github.com/terisuke/note_maker/issues/14).
+- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
-- Quality and coverage: [#29](https://github.com/terisuke/note_maker/issues/29), [#13](https://github.com/terisuke/note_maker/issues/13).
+- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
-- Live media-matrix runner: [#57](https://github.com/terisuke/note_maker/issues/57), child of [#40](https://github.com/terisuke/note_maker/issues/40).
- 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).
## Final evaluation target
@@ -55,11 +60,11 @@ Each live run must record:
## Before the full Evo X2 media run
-There are three prerequisites before running the full multi-medium Evo X2 evaluation:
+The three prerequisites before running the full multi-medium Evo X2 evaluation are now mostly in place:
-1. **Persistence first**: #26 must land so media-matrix drafts, evaluations, regenerated sections, answer forks, and style guides can be saved and reopened. Repeated Evo X2 runs are too expensive to leave only as loose files.
-2. **Handler coverage gate**: #29 should run in parallel with #26 and must close before more endpoint-heavy UI work. #17-#25 added real handler surface; the next phase should harden it instead of adding more unguarded routes.
-3. **Scenario ownership**: #40 owns the live Evo X2 media-matrix quality target. [#57](https://github.com/terisuke/note_maker/issues/57) owns the reusable runner/aggregate evaluator that executes the matrix and writes comparable reports.
+1. **Persistence first**: #26 adds SQLite storage for sessions, briefs, source snapshots, drafts, verification, and section-regeneration versions. The next UI work can now persist product memory instead of only loose files.
+2. **Handler coverage gate**: #29 raises `internal/handlers` coverage to 80.0%, including SSE, edit/fork, template, regenerate-section, and SQLite driver selection paths.
+3. **Scenario ownership**: #57 adds the reusable live runner/aggregate evaluator. #40 remains the owner for actual Evo X2 Tailnet quality results.
## Parallel implementation plan
@@ -67,22 +72,20 @@ Use subagents with disjoint write scopes:
| Lane | Issue | Subagent role | Write scope | Done when |
|---|---|---|---|---|
-| A | [#26](https://github.com/terisuke/note_maker/issues/26) / [#14](https://github.com/terisuke/note_maker/issues/14) | SQLite worker | `internal/infrastructure/repository/sqlite`, repository interfaces, boot wiring, migrations | JSON store imports, sessions/guides/briefs/drafts persist, cross-persona tests pass |
-| B | [#29](https://github.com/terisuke/note_maker/issues/29) | Handler coverage worker | `internal/handlers/*_test.go`, coverage script/docs | `workflow.go` reaches the agreed coverage gate without real LLM/network |
-| C | [#57](https://github.com/terisuke/note_maker/issues/57), feeding [#40](https://github.com/terisuke/note_maker/issues/40) | Scenario metrics worker | `cmd/scenario/*`, `docs/validation/*`, Make targets | media-matrix live runner records endpoint/model/elapsed/score/runes/verification in aggregate JSON/Markdown |
+| A | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
+| B | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
+| C | [#40](https://github.com/terisuke/note_maker/issues/40) | Scenario metrics worker | `docs/validation/*`, live run artifacts | media-matrix live runner records endpoint/model/elapsed/score/runes/verification in aggregate JSON/Markdown for actual Evo X2 runs |
Lane A and Lane B can run immediately in parallel. Lane C can start by implementing offline/resumable runner mechanics now, but the full multi-case Evo X2 run should wait until Lane A provides persistence or until the user explicitly wants a one-off artifact-file run.
## Recommended order
-1. Merge this docs alignment PR.
-2. Start #26 and #29 in parallel.
-3. Merge #29 as soon as handler coverage is sufficient.
-4. Merge #26 once JSON import, SQLite schema, and restart recovery are proven.
-5. Use #57/#40 to run one media-matrix case per implementation phase, then run the full note/Qiita/Zenn/company-blog pass after the persistence layer is stable.
-6. Start #27 and #28 after #26; both depend on persistent projects/sessions/guides.
-7. Start #13 after #27/#28 have enough browser surface to justify E2E tests.
-8. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
+1. Merge the current #26/#29/#57 implementation PR.
+2. Run one bounded Evo X2 live case through #57 and attach it to #40 to verify the runner with real latency/score data.
+3. Start #27 and #28 in parallel so persisted sessions, guides, and draft artifacts become visible in the web app.
+4. Start #13 once the history/artifact UI has enough stable browser surface.
+5. Run the full note/Qiita/Zenn/company-blog media matrix under #40.
+6. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
## Why not run the full Evo X2 matrix now?
diff --git a/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
index 6cdd168..c735616 100644
--- a/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
+++ b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
@@ -101,7 +101,26 @@ First generate the offline matrix so the brief files exist:
SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
```
-Then run draft generation for one case at a time with an available local LLM and style artifacts. Example:
+Then run the aggregate live runner in planned mode first. This confirms the matrix can be read without calling an LLM:
+
+```sh
+SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/live_media_matrix
+```
+
+Expected planned-mode artifacts:
+
+- `tmp/media_matrix/live/aggregate.json`
+- `tmp/media_matrix/live/aggregate.md`
+
+To execute all cases against the configured Evo X2 Tailnet OpenAI-compatible API, use:
+
+```sh
+make scenario-media-matrix-live
+```
+
+The live runner executes `cmd/scenario/draft_generation` once per matrix case, records stdout/stderr per case, and writes the aggregate result table. Limit the run while debugging with `LIVE_MEDIA_MATRIX_CASES=cloudia_zenn_tutorial,cloudia_qiita_how_to`.
+
+For a single manual case, run draft generation directly with an available local LLM and style artifacts. Example:
```sh
RUN_LOCAL_LLM_SCENARIO=1 \
diff --git a/go.mod b/go.mod
index 7d2ff91..e03103a 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
+ github.com/mattn/go-sqlite3 v1.14.32
)
require (
diff --git a/go.sum b/go.sum
index 992f5e6..1001131 100644
--- a/go.sum
+++ b/go.sum
@@ -7,6 +7,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
diff --git a/go.work.sum b/go.work.sum
index 8fb2188..97ab641 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -23,6 +23,8 @@ github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
diff --git a/internal/handlers/generate_test.go b/internal/handlers/generate_test.go
index 9b22fe9..45ec113 100644
--- a/internal/handlers/generate_test.go
+++ b/internal/handlers/generate_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"testing"
@@ -57,6 +58,25 @@ func TestHandleGenerateArticleValidatesContract(t *testing.T) {
}
}
+func TestHandleGenerateArticleReportsServiceFailure(t *testing.T) {
+ request := httptest.NewRequest(http.MethodPost, "/api/generate", bytes.NewBufferString(`{"note_url":"https://note.com/u/n/n1","theme":"theme"}`))
+ response := httptest.NewRecorder()
+
+ handleGenerateArticle(&fakeArticleService{err: errors.New("llm unavailable")}, response, request)
+
+ assertErrorResponse(t, response, http.StatusInternalServerError, "ARTICLE_GENERATION_FAILED")
+}
+
+func TestGenerateArticleHandlerReportsInvalidRuntimeConfig(t *testing.T) {
+ t.Setenv("LLM_BASE_URL", "://bad-url")
+ request := httptest.NewRequest(http.MethodPost, "/api/generate", bytes.NewBufferString(`{"note_url":"https://note.com/u/n/n1","theme":"theme"}`))
+ response := httptest.NewRecorder()
+
+ GenerateArticleHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusInternalServerError, "GENERATOR_INITIALIZATION_FAILED")
+}
+
type fakeArticleService struct {
request domain.GenerationRequest
draft string
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
new file mode 100644
index 0000000..fca4212
--- /dev/null
+++ b/internal/handlers/handlers_test.go
@@ -0,0 +1,45 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestListModelsHandlerReturnsOpenAICompatibleModels(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/models" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"data":[{"id":"gemma4:31b"},{"id":"qwen3.6:27b"}]}`))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("LLM_MODEL", "gemma4:31b")
+
+ response := httptest.NewRecorder()
+ ListModelsHandler(response, httptest.NewRequest(http.MethodGet, "/api/models", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var models []string
+ if err := json.NewDecoder(response.Body).Decode(&models); err != nil {
+ t.Fatalf("decode models: %v", err)
+ }
+ if len(models) != 2 || models[0] != "gemma4:31b" || models[1] != "qwen3.6:27b" {
+ t.Fatalf("models = %#v", models)
+ }
+}
+
+func TestListModelsHandlerReportsInvalidRuntimeConfig(t *testing.T) {
+ t.Setenv("LLM_BASE_URL", "://bad-url")
+
+ response := httptest.NewRecorder()
+ ListModelsHandler(response, httptest.NewRequest(http.MethodGet, "/api/models", nil))
+
+ if response.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+}
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index b51853a..17ddcc4 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -23,12 +23,35 @@ import (
personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
"github.com/teradakousuke/note_maker/internal/infrastructure/llamacpp"
"github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+ sqliterepo "github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite"
sourcefetch "github.com/teradakousuke/note_maker/internal/infrastructure/source"
)
var workflowStore = newWorkflowStore()
-func newWorkflowStore() *memory.WorkflowStore {
+type workflowStoreBackend interface {
+ SaveAuthorStyle(authorstyleapp.AnalyzeResult) error
+ GetAuthorStyle(string) (authorstyleapp.AnalyzeResult, bool)
+ SaveSession(briefdomain.ArticleBriefSession) error
+ GetSession(string) (briefdomain.ArticleBriefSession, bool)
+ SaveBrief(string, briefdomain.ArticleBrief) error
+ GetBrief(string) (briefdomain.ArticleBrief, bool)
+ GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool)
+}
+
+func newWorkflowStore() workflowStoreBackend {
+ driver := strings.ToLower(strings.TrimSpace(os.Getenv("WORKFLOW_STORE_DRIVER")))
+ if driver == "sqlite" {
+ path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
+ if path == "" {
+ path = "data/workflow_store.db"
+ }
+ store, err := sqliterepo.NewWorkflowStore(path)
+ if err == nil {
+ return store
+ }
+ panic(fmt.Sprintf("initialize sqlite workflow store: %v", err))
+ }
path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
if path == "" {
path = "data/workflow_store.json"
diff --git a/internal/handlers/workflow_edit_test.go b/internal/handlers/workflow_edit_test.go
index ff0d1fc..ac1d6fa 100644
--- a/internal/handlers/workflow_edit_test.go
+++ b/internal/handlers/workflow_edit_test.go
@@ -71,3 +71,56 @@ func TestEditBriefAnswerHandlerForksSession(t *testing.T) {
t.Fatal("forked session was not saved")
}
}
+
+func TestEditBriefAnswerHandlerValidatesStoredSessionAndAnswer(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ session, err := briefdomain.NewArticleBriefSession("session-1", "style-1")
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if _, err := session.RecordAnswer("Local article generation."); err != nil {
+ t.Fatalf("record answer: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ sessionID string
+ answerID string
+ status int
+ code string
+ }{
+ {
+ name: "missing session",
+ sessionID: "missing",
+ answerID: briefdomain.QuestionIDTheme,
+ status: http.StatusNotFound,
+ code: "BRIEF_SESSION_NOT_FOUND",
+ },
+ {
+ name: "missing answer",
+ sessionID: "session-1",
+ answerID: briefdomain.QuestionIDOpeningEpisode,
+ status: http.StatusBadRequest,
+ code: "BRIEF_ANSWER_EDIT_FAILED",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := bytes.NewBufferString(`{"content":"Edited answer"}`)
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/"+tt.sessionID+"/answers/"+tt.answerID+"/edit", body)
+ request = mux.SetURLVars(request, map[string]string{
+ "id": tt.sessionID,
+ "answer_id": tt.answerID,
+ })
+ response := httptest.NewRecorder()
+
+ EditBriefAnswerHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
diff --git a/internal/handlers/workflow_handler_test.go b/internal/handlers/workflow_handler_test.go
index e9e4957..b02d76f 100644
--- a/internal/handlers/workflow_handler_test.go
+++ b/internal/handlers/workflow_handler_test.go
@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -63,6 +64,99 @@ func TestSeedAuthorStyleHandlerRejectsUnknownPersona(t *testing.T) {
assertErrorResponse(t, response, http.StatusBadRequest, "UNKNOWN_PERSONA")
}
+func TestSeedAuthorStyleHandlerRejectsInvalidJSON(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{`))
+ response := httptest.NewRecorder()
+
+ SeedAuthorStyleHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusBadRequest, "INVALID_REQUEST_FORMAT")
+}
+
+func TestRefineStyleGuideWithModelUsesStyleRuntime(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Model != "style-test" {
+ t.Fatalf("model = %q, want style-test", payload.Model)
+ }
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"- 導入は具体的な違和感から始める\n- 締めは読者の次の一歩に寄せる"}}]}`))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+
+ result := setupWorkflowStyle(t)
+ refined, err := refineStyleGuideWithModel(context.Background(), result, "style-test")
+ if err != nil {
+ t.Fatalf("refine style guide: %v", err)
+ }
+ if !strings.Contains(refined, "具体的な違和感") {
+ t.Fatalf("unexpected refined guide: %s", refined)
+ }
+}
+
+func TestAnalyzeAuthorStyleHandlerFetchesHTMLArticle(t *testing.T) {
+ articleServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`HTML article HTML article ` +
+ strings.Repeat(`僕はHTML記事から文体を抽出し、過去記事の調子を保存する流れを確認します。
`, 12) +
+ ` `))
+ }))
+ defer articleServer.Close()
+ workflowStore = memory.NewWorkflowStore()
+
+ body := `{"article_urls":[` + quoteJSONString(articleServer.URL+"/article") + `],"limit":1}`
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/analyze", bytes.NewBufferString(body))
+ response := httptest.NewRecorder()
+
+ AnalyzeAuthorStyleHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload authorStyleResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.ProfileID == "" || payload.GuideID == "" || payload.ArticleCount != 1 {
+ t.Fatalf("unexpected analysis response: %#v", payload)
+ }
+ if _, ok := workflowStore.GetAuthorStyle(payload.ID); !ok {
+ t.Fatal("analysis result was not saved")
+ }
+}
+
+func TestAnalyzeAuthorStyleHandlerValidatesRequest(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ tests := []struct {
+ name string
+ body string
+ status int
+ code string
+ }{
+ {name: "bad json", body: `{`, status: http.StatusBadRequest, code: "INVALID_REQUEST_FORMAT"},
+ {name: "missing source", body: `{}`, status: http.StatusInternalServerError, code: "AUTHOR_STYLE_ANALYSIS_FAILED"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/analyze", bytes.NewBufferString(tt.body))
+ response := httptest.NewRecorder()
+
+ AnalyzeAuthorStyleHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
+
func TestGetAuthorStyleHandlerNotFound(t *testing.T) {
workflowStore = memory.NewWorkflowStore()
request := httptest.NewRequest(http.MethodGet, "/api/author-styles/missing", nil)
@@ -208,6 +302,17 @@ func TestGetBriefSessionHandlerReturnsStoredProgressAndCompletedBrief(t *testing
}
}
+func TestGetBriefSessionHandlerRejectsMissingSession(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodGet, "/api/brief-sessions/missing", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ GetBriefSessionHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusNotFound, "BRIEF_SESSION_NOT_FOUND")
+}
+
func TestAnswerBriefSessionHandlerRecordsAnswerWithoutLLM(t *testing.T) {
style := setupWorkflowStyle(t)
session, err := briefdomain.NewArticleBriefSession("session-answer", style.Profile.ID)
@@ -269,6 +374,66 @@ func TestAnswerBriefSessionHandlerSkipDeepDiveCompletesAndSavesBrief(t *testing.
}
}
+func TestAnswerBriefSessionHandlerValidatesRequestAndSessionState(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ tests := []struct {
+ name string
+ sessionID string
+ body string
+ status int
+ code string
+ setup func(t *testing.T)
+ }{
+ {
+ name: "bad json",
+ sessionID: "session-any",
+ body: `{`,
+ status: http.StatusBadRequest,
+ code: "INVALID_REQUEST_FORMAT",
+ },
+ {
+ name: "missing session",
+ sessionID: "missing",
+ body: `{"content":"answer"}`,
+ status: http.StatusNotFound,
+ code: "BRIEF_SESSION_NOT_FOUND",
+ },
+ {
+ name: "skip before complete",
+ sessionID: "session-incomplete",
+ body: `{"skip_deep_dive":true}`,
+ status: http.StatusBadRequest,
+ code: "BRIEF_SESSION_INCOMPLETE",
+ setup: func(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session, err := briefdomain.NewArticleBriefSession("session-incomplete", style.Profile.ID)
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ if tt.setup != nil {
+ tt.setup(t)
+ }
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/"+tt.sessionID+"/answers", bytes.NewBufferString(tt.body))
+ request = mux.SetURLVars(request, map[string]string{"id": tt.sessionID})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
+
func TestAnswerBriefSessionHandlerStreamsFirstAnswerWithoutLLM(t *testing.T) {
style := setupWorkflowStyle(t)
session, err := briefdomain.NewArticleBriefSession("session-stream-answer", style.Profile.ID)
@@ -299,6 +464,114 @@ func TestAnswerBriefSessionHandlerStreamsFirstAnswerWithoutLLM(t *testing.T) {
}
}
+func TestAnswerBriefSessionHandlerStreamsGeneratedFollowUp(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Model != "brief-test" {
+ t.Fatalf("model = %q, want brief-test", payload.Model)
+ }
+ if !payload.Stream {
+ t.Fatal("follow-up generation should use streaming completions")
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ for _, chunk := range []string{
+ "「A handler test failed before reaching a networked LLM.」というご回答を踏まえて、",
+ "どの判断を最初に説明しますか?",
+ } {
+ _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":` + quoteJSONString(chunk) + `}}]}` + "\n\n"))
+ }
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("BRIEF_LLM_MODEL", "brief-test")
+
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-follow-up-stream", style.Profile.ID)
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/session-follow-up-stream/answers", bytes.NewBufferString(`{"content":"The first follow-up answer adds the concrete decision point."}`))
+ request.Header.Set("Accept", "text/event-stream")
+ request = mux.SetURLVars(request, map[string]string{"id": "session-follow-up-stream"})
+ response := httptest.NewRecorder()
+
+ AnswerBriefSessionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if got := response.Header().Get("Content-Type"); !strings.Contains(got, "text/event-stream") {
+ t.Fatalf("unexpected content type: %s", got)
+ }
+ stream := response.Body.String()
+ for _, want := range []string{"event: status", "follow_up_generation_started", "event: chunk", "event: result", "event: done"} {
+ if !strings.Contains(stream, want) {
+ t.Fatalf("stream missing %q:\n%s", want, stream)
+ }
+ }
+ if !strings.Contains(stream, "どの判断を最初に説明しますか?") {
+ t.Fatalf("stream missing generated follow-up question:\n%s", stream)
+ }
+ saved, ok := workflowStore.GetSession("session-follow-up-stream")
+ if !ok {
+ t.Fatal("answered session was not saved")
+ }
+ if got := len(saved.DeepDiveAnswers()); got != 1 {
+ t.Fatalf("deep dive answer count = %d, want 1; answers = %#v", got, saved.Answers)
+ }
+}
+
+func TestLLMFollowUpGeneratorUsesNonStreamingRuntime(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Model != "brief-nonstream-test" {
+ t.Fatalf("model = %q, want brief-nonstream-test", payload.Model)
+ }
+ if payload.Stream {
+ t.Fatal("non-stream follow-up should not request SSE")
+ }
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"「Local workflow tests」というご回答を踏まえて、どの判断を一番詳しく説明しますか?"}}]}`))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-follow-up-nonstream", style.Profile.ID)
+ target := session.Questions[0]
+ answer, ok := session.AnswerForQuestion(target.ID)
+ if !ok {
+ t.Fatalf("missing answer for %s", target.ID)
+ }
+ generator := llmFollowUpGenerator{model: "brief-nonstream-test", styleGuideMarkdown: style.Guide.Markdown}
+
+ question, err := generator.GenerateFollowUp(context.Background(), session, target, answer, 1)
+ if err != nil {
+ t.Fatalf("generate follow-up: %v", err)
+ }
+ if !strings.Contains(question, "どの判断") {
+ t.Fatalf("unexpected question: %s", question)
+ }
+}
+
func TestAnswerBriefSessionHandlerRejectsMissingSession(t *testing.T) {
workflowStore = memory.NewWorkflowStore()
request := httptest.NewRequest(http.MethodPost, "/api/brief-sessions/missing/answers", bytes.NewBufferString(`{"content":"answer"}`))
diff --git a/internal/handlers/workflow_mode_test.go b/internal/handlers/workflow_mode_test.go
index 3d8de57..b96798d 100644
--- a/internal/handlers/workflow_mode_test.go
+++ b/internal/handlers/workflow_mode_test.go
@@ -85,6 +85,63 @@ func TestGetBriefSessionTemplateHandlerReturnsComposedQuestions(t *testing.T) {
}
}
+func TestGetBriefSessionTemplateHandlerDefaultsPersonaFormat(t *testing.T) {
+ response := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/api/brief-sessions/templates?persona_id=cloudia", nil)
+
+ GetBriefSessionTemplateHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
+ }
+ var payload briefSessionTemplateResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode template: %v", err)
+ }
+ if payload.PersonaID != personadomain.IDCloudia || payload.OutputFormatID != outputformat.IDZennArticle {
+ t.Fatalf("unexpected defaulted template identity: %#v", payload)
+ }
+ if !hasQuestionJSON(payload.Questions, briefdomain.QuestionIDTargetStack) {
+ t.Fatalf("default cloudia format did not include technical questions: %#v", payload.Questions)
+ }
+ if !hasQuestionJSON(payload.Questions, briefdomain.QuestionIDCloudiaViewpoint) {
+ t.Fatalf("default cloudia template missing persona extension: %#v", payload.Questions)
+ }
+}
+
+func TestGetBriefSessionTemplateHandlerValidatesInputs(t *testing.T) {
+ tests := []struct {
+ name string
+ target string
+ status int
+ code string
+ }{
+ {
+ name: "unknown persona",
+ target: "/api/brief-sessions/templates?persona_id=missing",
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_PERSONA",
+ },
+ {
+ name: "unknown output format",
+ target: "/api/brief-sessions/templates?persona_id=terisuke&format_id=missing",
+ status: http.StatusBadRequest,
+ code: "UNKNOWN_OUTPUT_FORMAT",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, tt.target, nil)
+
+ GetBriefSessionTemplateHandler(response, request)
+
+ assertErrorResponse(t, response, tt.status, tt.code)
+ })
+ }
+}
+
func hasQuestionJSON(questions []articleQuestionJSON, id string) bool {
for _, question := range questions {
if question.ID == id {
diff --git a/internal/handlers/workflow_regenerate_section_test.go b/internal/handlers/workflow_regenerate_section_test.go
index 7c756fe..63a4958 100644
--- a/internal/handlers/workflow_regenerate_section_test.go
+++ b/internal/handlers/workflow_regenerate_section_test.go
@@ -75,3 +75,59 @@ func TestRegenerateDraftSectionHandlerReplacesOnlyTargetSection(t *testing.T) {
t.Fatalf("old section remained:\n%s", payload.UpdatedDraftMarkdown)
}
}
+
+func TestRegenerateDraftSectionHandlerDerivesContextFromPathSession(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"## 検証\n\n完了済みセッションから文脈を復元して書き直します。"}}]}`))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("DRAFT_LLM_MODEL", "gemma4:31b")
+
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-path-context", style.Profile.ID)
+ session.MarkDeepDiveSkipped()
+ if _, err := session.Complete(); err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+
+ draft := "# Title\n\nIntro\n\n## 実装\n\nここは残します。\n\n## 検証\n\n古い検証内容です。\n"
+ body := `{"section_anchor":"検証","draft_markdown":` + quoteJSONString(draft) + `}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts/session-path-context/regenerate-section", bytes.NewBufferString(body))
+ request = mux.SetURLVars(request, map[string]string{"id": "session-path-context"})
+ response := httptest.NewRecorder()
+
+ RegenerateDraftSectionHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload regenerateDraftSectionResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !strings.Contains(payload.UpdatedDraftMarkdown, "## 実装\n\nここは残します。") {
+ t.Fatalf("non-target section changed:\n%s", payload.UpdatedDraftMarkdown)
+ }
+ if !strings.Contains(payload.UpdatedDraftMarkdown, "完了済みセッションから文脈を復元") {
+ t.Fatalf("updated draft missing regenerated section:\n%s", payload.UpdatedDraftMarkdown)
+ }
+}
+
+func TestRegenerateDraftSectionHandlerRejectsMissingContext(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ body := `{"section_anchor":"実装","draft_markdown":"# Title\n\n## 実装\n\n本文"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts/missing/regenerate-section", bytes.NewBufferString(body))
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ RegenerateDraftSectionHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusBadRequest, "DRAFT_CONTEXT_NOT_FOUND")
+}
diff --git a/internal/handlers/workflow_store_test.go b/internal/handlers/workflow_store_test.go
new file mode 100644
index 0000000..b9de5b5
--- /dev/null
+++ b/internal/handlers/workflow_store_test.go
@@ -0,0 +1,20 @@
+package handlers
+
+import (
+ "path/filepath"
+ "testing"
+
+ sqliterepo "github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite"
+)
+
+func TestNewWorkflowStoreCanUseSQLiteDriver(t *testing.T) {
+ t.Setenv("WORKFLOW_STORE_DRIVER", "sqlite")
+ t.Setenv("WORKFLOW_STORE_PATH", filepath.Join(t.TempDir(), "workflow.db"))
+
+ store := newWorkflowStore()
+ sqliteStore, ok := store.(*sqliterepo.WorkflowStore)
+ if !ok {
+ t.Fatalf("store type = %T, want sqlite workflow store", store)
+ }
+ t.Cleanup(func() { _ = sqliteStore.Close() })
+}
diff --git a/internal/handlers/workflow_stream_test.go b/internal/handlers/workflow_stream_test.go
index 378b56f..177776a 100644
--- a/internal/handlers/workflow_stream_test.go
+++ b/internal/handlers/workflow_stream_test.go
@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
+ "encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -77,6 +78,142 @@ func TestGenerateDraftHandlerStreamsSSE(t *testing.T) {
}
}
+func TestGenerateDraftHandlerStreamsFromCompletedSessionFallback(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Model != "draft-test" {
+ t.Fatalf("model = %q, want draft-test", payload.Model)
+ }
+ if !payload.Stream {
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"PASS\nSummary: ブリーフに沿っています"}}]}`))
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ for _, chunk := range []string{
+ "# Local workflow tests\n\n",
+ strings.Repeat("僕はHTTPハンドラーのテストから、保存済みブリーフがなくても完了済みセッションを使って下書きを生成できることを確認しました。\n\n", 18),
+ "## 具体的な検証\n\n" + strings.Repeat("僕はSSEのstatus、chunk、result、doneが順に返ることを見ながら、外部サービスなしで振る舞いを固定しました。\n\n", 12),
+ "## 次の一歩\n\n" + strings.Repeat("僕は回帰を見つけやすくするため、入力の省略時にも既存セッションのpersonaとformatが使われることを残します。\n\n", 8),
+ } {
+ _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":` + quoteJSONString(chunk) + `}}]}` + "\n\n"))
+ }
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("DRAFT_LLM_MODEL", "draft-test")
+ t.Setenv("VERIFY_LLM_MODEL", "draft-test")
+
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session_stream_completed", style.Profile.ID)
+ session.MarkDeepDiveSkipped()
+ if _, err := session.Complete(); err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session_stream_completed","draft_model":"draft-test","verify_model":"draft-test"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBufferString(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "text/event-stream")
+ response := httptest.NewRecorder()
+
+ GenerateDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if got := response.Header().Get("Content-Type"); !strings.Contains(got, "text/event-stream") {
+ t.Fatalf("unexpected content type: %s", got)
+ }
+ stream := response.Body.String()
+ for _, want := range []string{"event: status", "draft_generation_started", "event: chunk", "event: result", "event: done", "# Local workflow tests"} {
+ if !strings.Contains(stream, want) {
+ t.Fatalf("stream missing %q:\n%s", want, stream)
+ }
+ }
+}
+
+func TestGenerateDraftHandlerReturnsJSONDraft(t *testing.T) {
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Stream {
+ t.Fatal("JSON draft path should not request streaming")
+ }
+ switch payload.Model {
+ case "draft-json-test":
+ content := "# Local workflow tests\n\n" +
+ strings.Repeat("僕は保存済みブリーフを使い、通常のJSONレスポンスでも下書きが返ることを確認しました。\n\n", 18) +
+ "## 検証\n\n" +
+ strings.Repeat("僕はpersonaとformatをブリーフから復元し、Note向けMarkdownとして扱える形に整えます。\n\n", 12) +
+ "## 次の一歩\n\n" +
+ strings.Repeat("僕はこのテストでSSE以外の生成経路も固定し、UI追加前の回帰を抑えます。\n\n", 8)
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":` + quoteJSONString(content) + `}}]}`))
+ case "verify-json-test":
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"PASS\nSummary: 文体とブリーフに沿っています"}}]}`))
+ default:
+ t.Fatalf("unexpected model: %s", payload.Model)
+ }
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("DRAFT_LLM_MODEL", "draft-json-test")
+ t.Setenv("VERIFY_LLM_MODEL", "verify-json-test")
+
+ style := setupWorkflowStyle(t)
+ brief := briefdomain.ArticleBrief{
+ StyleProfileID: style.Profile.ID,
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ Theme: "JSONで下書きを返す",
+ Reader: "HTTP handlerを保守する開発者",
+ MustInclude: "通常レスポンス、検証、文体評価",
+ TargetLengthStructure: "2500字前後",
+ ToneStance: "内省的だが実装に寄せる",
+ }
+ if err := workflowStore.SaveBrief("session_json_draft", brief); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session_json_draft","draft_model":"draft-json-test","verify_model":"verify-json-test"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBufferString(body))
+ request.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+
+ GenerateDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload generateDraftResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !strings.Contains(payload.Draft, "# Local workflow tests") || !payload.Verification.Performed {
+ t.Fatalf("unexpected draft response: %#v", payload)
+ }
+}
+
func quoteJSONString(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `"`, `\"`)
diff --git a/internal/infrastructure/repository/sqlite/migrations/0001_workflow.sql b/internal/infrastructure/repository/sqlite/migrations/0001_workflow.sql
new file mode 100644
index 0000000..5ca20b8
--- /dev/null
+++ b/internal/infrastructure/repository/sqlite/migrations/0001_workflow.sql
@@ -0,0 +1,162 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ applied_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ metadata_json TEXT NOT NULL DEFAULT '{}'
+);
+
+CREATE TABLE IF NOT EXISTS articles (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ persona_id TEXT NOT NULL,
+ output_format_id TEXT NOT NULL,
+ brief_session_id TEXT,
+ current_draft_id TEXT,
+ title TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ metadata_json TEXT NOT NULL DEFAULT '{}',
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_articles_project_id ON articles(project_id);
+CREATE INDEX IF NOT EXISTS idx_articles_brief_session_id ON articles(brief_session_id);
+
+CREATE TABLE IF NOT EXISTS author_style_results (
+ id TEXT PRIMARY KEY,
+ profile_id TEXT NOT NULL,
+ guide_id TEXT NOT NULL,
+ source_json TEXT NOT NULL,
+ profile_json TEXT NOT NULL,
+ guide_json TEXT NOT NULL,
+ article_count INTEGER NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_author_style_results_profile_id ON author_style_results(profile_id);
+CREATE INDEX IF NOT EXISTS idx_author_style_results_guide_id ON author_style_results(guide_id);
+
+CREATE TABLE IF NOT EXISTS author_source_articles (
+ analysis_id TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ article_id TEXT NOT NULL DEFAULT '',
+ url TEXT NOT NULL DEFAULT '',
+ title TEXT NOT NULL DEFAULT '',
+ fetched_at TEXT NOT NULL,
+ content_hash TEXT NOT NULL DEFAULT '',
+ source_json TEXT NOT NULL,
+ PRIMARY KEY (analysis_id, position),
+ FOREIGN KEY (analysis_id) REFERENCES author_style_results(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_author_source_articles_hash ON author_source_articles(content_hash);
+
+CREATE TABLE IF NOT EXISTS source_selector_snapshots (
+ id TEXT PRIMARY KEY,
+ scope_type TEXT NOT NULL,
+ scope_id TEXT NOT NULL,
+ selector_json TEXT NOT NULL,
+ profile_json TEXT,
+ article_json TEXT,
+ content_hash TEXT NOT NULL DEFAULT '',
+ fetched_at TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_source_selector_snapshots_scope ON source_selector_snapshots(scope_type, scope_id);
+CREATE INDEX IF NOT EXISTS idx_source_selector_snapshots_hash ON source_selector_snapshots(content_hash);
+
+CREATE TABLE IF NOT EXISTS brief_sessions (
+ id TEXT PRIMARY KEY,
+ style_profile_id TEXT NOT NULL,
+ persona_id TEXT NOT NULL,
+ output_format_id TEXT NOT NULL,
+ parent_session_id TEXT NOT NULL DEFAULT '',
+ phase TEXT NOT NULL,
+ completed INTEGER NOT NULL,
+ deep_dive_skipped INTEGER NOT NULL,
+ question_template_version TEXT NOT NULL DEFAULT '',
+ questions_json TEXT NOT NULL,
+ answers_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_brief_sessions_style_profile_id ON brief_sessions(style_profile_id);
+CREATE INDEX IF NOT EXISTS idx_brief_sessions_persona_format ON brief_sessions(persona_id, output_format_id);
+
+CREATE TABLE IF NOT EXISTS brief_answers (
+ session_id TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ question_id TEXT NOT NULL,
+ content TEXT NOT NULL,
+ flow_type TEXT NOT NULL,
+ target_question_id TEXT NOT NULL DEFAULT '',
+ follow_up_index INTEGER NOT NULL DEFAULT 0,
+ parent_answer_id TEXT NOT NULL DEFAULT '',
+ answer_json TEXT NOT NULL,
+ PRIMARY KEY (session_id, position),
+ FOREIGN KEY (session_id) REFERENCES brief_sessions(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_brief_answers_question_id ON brief_answers(question_id);
+
+CREATE TABLE IF NOT EXISTS briefs (
+ session_id TEXT PRIMARY KEY,
+ style_profile_id TEXT NOT NULL,
+ persona_id TEXT NOT NULL DEFAULT '',
+ output_format_id TEXT NOT NULL DEFAULT '',
+ brief_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES brief_sessions(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS drafts (
+ id TEXT PRIMARY KEY,
+ article_id TEXT,
+ session_id TEXT NOT NULL DEFAULT '',
+ style_profile_id TEXT NOT NULL DEFAULT '',
+ persona_id TEXT NOT NULL DEFAULT '',
+ output_format_id TEXT NOT NULL DEFAULT '',
+ version INTEGER NOT NULL,
+ markdown TEXT NOT NULL,
+ content_hash TEXT NOT NULL,
+ evaluation_json TEXT NOT NULL DEFAULT '{}',
+ verification_json TEXT NOT NULL DEFAULT '{}',
+ question_template_version TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ UNIQUE (article_id, version),
+ FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_drafts_article_id ON drafts(article_id);
+CREATE INDEX IF NOT EXISTS idx_drafts_session_id ON drafts(session_id);
+CREATE INDEX IF NOT EXISTS idx_drafts_content_hash ON drafts(content_hash);
+
+CREATE TABLE IF NOT EXISTS section_regenerations (
+ id TEXT PRIMARY KEY,
+ draft_id TEXT NOT NULL,
+ article_id TEXT,
+ section_anchor TEXT NOT NULL,
+ section_heading TEXT NOT NULL DEFAULT '',
+ base_version INTEGER NOT NULL,
+ version INTEGER NOT NULL,
+ replacement_markdown TEXT NOT NULL,
+ updated_draft_markdown TEXT NOT NULL,
+ updated_content_hash TEXT NOT NULL,
+ verification_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (draft_id) REFERENCES drafts(id) ON DELETE CASCADE,
+ FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_section_regenerations_draft_id ON section_regenerations(draft_id);
+CREATE INDEX IF NOT EXISTS idx_section_regenerations_article_id ON section_regenerations(article_id);
diff --git a/internal/infrastructure/repository/sqlite/workflow.go b/internal/infrastructure/repository/sqlite/workflow.go
new file mode 100644
index 0000000..e20005c
--- /dev/null
+++ b/internal/infrastructure/repository/sqlite/workflow.go
@@ -0,0 +1,950 @@
+package sqlite
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+ authorstyleapp "github.com/teradakousuke/note_maker/internal/application/authorstyle"
+ 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"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+//go:embed migrations/*.sql
+var migrationFiles embed.FS
+
+const (
+ driverName = "sqlite3"
+ defaultTemplateVersion = "brief-template/v1"
+)
+
+// WorkflowStore persists workflow state in SQLite while keeping the memory
+// store's public behavior for current callers.
+type WorkflowStore struct {
+ db *sql.DB
+}
+
+// ProjectRecord is the persisted project aggregate prepared for history UI.
+type ProjectRecord struct {
+ ID string
+ Name string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ Metadata map[string]any
+}
+
+// ArticleRecord is one target deliverable inside a project.
+type ArticleRecord struct {
+ ID string
+ ProjectID string
+ PersonaID string
+ OutputFormatID string
+ BriefSessionID string
+ CurrentDraftID string
+ Title string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ Metadata map[string]any
+}
+
+// SourceSnapshotRecord stores a source selector and the normalized fetch result
+// available at the time it was used.
+type SourceSnapshotRecord struct {
+ ID string
+ ScopeType string
+ ScopeID string
+ Selector sourcedomain.Ref
+ Profile *sourcedomain.ProfileSnapshot
+ Article *sourcedomain.ArticleSnapshot
+ ContentHash string
+ FetchedAt time.Time
+ CreatedAt time.Time
+}
+
+// DraftRecord is one persisted generated draft version.
+type DraftRecord struct {
+ ID string
+ ArticleID string
+ SessionID string
+ StyleProfileID string
+ PersonaID string
+ OutputFormatID string
+ Version int
+ Markdown string
+ ContentHash string
+ Evaluation draftapp.StyleEvaluation
+ Verification draftapp.FinalVerification
+ QuestionTemplateVersion string
+ CreatedAt time.Time
+}
+
+// SectionRegenerationRecord stores the result of regenerating one section from
+// an existing draft version.
+type SectionRegenerationRecord struct {
+ ID string
+ DraftID string
+ ArticleID string
+ SectionAnchor string
+ SectionHeading string
+ BaseVersion int
+ Version int
+ ReplacementMarkdown string
+ UpdatedDraftMarkdown string
+ UpdatedContentHash string
+ Verification draftapp.FinalVerification
+ CreatedAt time.Time
+}
+
+// NewWorkflowStore opens or creates a SQLite workflow store and applies migrations.
+func NewWorkflowStore(path string) (*WorkflowStore, error) {
+ if strings.TrimSpace(path) == "" {
+ return nil, fmt.Errorf("sqlite workflow store path is required")
+ }
+ if path != ":memory:" {
+ path = filepath.Clean(path)
+ }
+ db, err := sql.Open(driverName, path)
+ if err != nil {
+ return nil, fmt.Errorf("open sqlite workflow store: %w", err)
+ }
+ store := &WorkflowStore{db: db}
+ if err := store.configure(); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ if err := store.Migrate(); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+// NewWorkflowStoreDB wraps an existing database handle and applies migrations.
+func NewWorkflowStoreDB(db *sql.DB) (*WorkflowStore, error) {
+ if db == nil {
+ return nil, fmt.Errorf("sqlite db is required")
+ }
+ store := &WorkflowStore{db: db}
+ if err := store.configure(); err != nil {
+ return nil, err
+ }
+ if err := store.Migrate(); err != nil {
+ return nil, err
+ }
+ return store, nil
+}
+
+// DB returns the underlying database handle for advanced queries in focused tests/tools.
+func (s *WorkflowStore) DB() *sql.DB {
+ return s.db
+}
+
+// Close releases the underlying SQLite connection pool.
+func (s *WorkflowStore) Close() error {
+ if s == nil || s.db == nil {
+ return nil
+ }
+ return s.db.Close()
+}
+
+func (s *WorkflowStore) configure() error {
+ if _, err := s.db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
+ return fmt.Errorf("enable sqlite foreign keys: %w", err)
+ }
+ return nil
+}
+
+// Migrate applies embedded schema migrations once.
+func (s *WorkflowStore) Migrate() error {
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
+ return fmt.Errorf("prepare schema migrations table: %w", err)
+ }
+ entries, err := migrationFiles.ReadDir("migrations")
+ if err != nil {
+ return fmt.Errorf("read migrations: %w", err)
+ }
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
+ continue
+ }
+ version, err := migrationVersion(entry.Name())
+ if err != nil {
+ return err
+ }
+ var exists int
+ err = s.db.QueryRow(`SELECT 1 FROM schema_migrations WHERE version = ?`, version).Scan(&exists)
+ if err == nil {
+ continue
+ }
+ if err != sql.ErrNoRows {
+ return fmt.Errorf("check migration %s: %w", entry.Name(), err)
+ }
+ body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", entry.Name(), err)
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
+ }
+ if _, err := tx.Exec(string(body)); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
+ }
+ if _, err := tx.Exec(`INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)`, version, entry.Name(), formatTime(nowUTC())); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("record migration %s: %w", entry.Name(), err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
+ }
+ }
+ return nil
+}
+
+// SaveAuthorStyle stores an author style analysis result.
+func (s *WorkflowStore) SaveAuthorStyle(result authorstyleapp.AnalyzeResult) error {
+ if result.ID == "" {
+ return fmt.Errorf("author style result id is required")
+ }
+ if err := result.Profile.Validate(); err != nil {
+ return err
+ }
+ if err := result.Guide.Validate(); err != nil {
+ return err
+ }
+ createdAt := result.CreatedAt
+ if createdAt.IsZero() {
+ createdAt = nowUTC()
+ }
+ sourceJSON, err := marshalString(result.Source)
+ if err != nil {
+ return fmt.Errorf("encode author source: %w", err)
+ }
+ profileJSON, err := marshalString(result.Profile)
+ if err != nil {
+ return fmt.Errorf("encode author profile: %w", err)
+ }
+ guideJSON, err := marshalString(result.Guide)
+ if err != nil {
+ return fmt.Errorf("encode writing style guide: %w", err)
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin save author style: %w", err)
+ }
+ defer rollbackUnlessDone(tx)
+ _, err = tx.Exec(`
+INSERT INTO author_style_results (id, profile_id, guide_id, source_json, profile_json, guide_json, article_count, created_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ profile_id = excluded.profile_id,
+ guide_id = excluded.guide_id,
+ source_json = excluded.source_json,
+ profile_json = excluded.profile_json,
+ guide_json = excluded.guide_json,
+ article_count = excluded.article_count,
+ created_at = excluded.created_at`,
+ result.ID, result.Profile.ID, result.Guide.ID, sourceJSON, profileJSON, guideJSON, result.ArticleCount, formatTime(createdAt))
+ if err != nil {
+ return fmt.Errorf("save author style: %w", err)
+ }
+ if _, err := tx.Exec(`DELETE FROM author_source_articles WHERE analysis_id = ?`, result.ID); err != nil {
+ return fmt.Errorf("replace source articles: %w", err)
+ }
+ for i, article := range result.Source.Articles {
+ articleJSON, err := marshalString(article)
+ if err != nil {
+ return fmt.Errorf("encode source article %d: %w", i, err)
+ }
+ contentHash := hashString(strings.Join([]string{article.ID, article.URL, article.Title}, "\x00"))
+ _, err = tx.Exec(`
+INSERT INTO author_source_articles (analysis_id, position, article_id, url, title, fetched_at, content_hash, source_json)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ result.ID, i, article.ID, article.URL, article.Title, formatTime(article.At), contentHash, articleJSON)
+ if err != nil {
+ return fmt.Errorf("save source article %d: %w", i, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit save author style: %w", err)
+ }
+ return nil
+}
+
+// GetAuthorStyle returns an analysis result by analysis ID, profile ID, or guide ID.
+func (s *WorkflowStore) GetAuthorStyle(id string) (authorstyleapp.AnalyzeResult, bool) {
+ var result authorstyleapp.AnalyzeResult
+ var sourceJSON, profileJSON, guideJSON, createdAt string
+ err := s.db.QueryRow(`
+SELECT id, source_json, profile_json, guide_json, article_count, created_at
+FROM author_style_results
+WHERE id = ? OR profile_id = ? OR guide_id = ?
+ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, created_at DESC
+LIMIT 1`, id, id, id, id).Scan(&result.ID, &sourceJSON, &profileJSON, &guideJSON, &result.ArticleCount, &createdAt)
+ if err != nil {
+ return authorstyleapp.AnalyzeResult{}, false
+ }
+ if err := unmarshalString(sourceJSON, &result.Source); err != nil {
+ return authorstyleapp.AnalyzeResult{}, false
+ }
+ if err := unmarshalString(profileJSON, &result.Profile); err != nil {
+ return authorstyleapp.AnalyzeResult{}, false
+ }
+ if err := unmarshalString(guideJSON, &result.Guide); err != nil {
+ return authorstyleapp.AnalyzeResult{}, false
+ }
+ result.CreatedAt = parseTime(createdAt)
+ return result, true
+}
+
+// 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)
+ if !ok {
+ return authordomain.AuthorStyleProfile{}, authordomain.WritingStyleGuide{}, false
+ }
+ return result.Profile, result.Guide, true
+}
+
+// SaveSession stores a brief interview session.
+func (s *WorkflowStore) SaveSession(session briefdomain.ArticleBriefSession) error {
+ if session.ID == "" {
+ return fmt.Errorf("brief session id is required")
+ }
+ if strings.TrimSpace(session.StyleProfileID) == "" {
+ return fmt.Errorf("brief session style profile id is required")
+ }
+ questionsJSON, err := marshalString(session.Questions)
+ if err != nil {
+ return fmt.Errorf("encode session questions: %w", err)
+ }
+ answersJSON, err := marshalString(session.Answers)
+ if err != nil {
+ return fmt.Errorf("encode session answers: %w", err)
+ }
+ now := nowUTC()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin save session: %w", err)
+ }
+ defer rollbackUnlessDone(tx)
+ _, err = tx.Exec(`
+INSERT INTO brief_sessions (
+ id, style_profile_id, persona_id, output_format_id, parent_session_id, phase,
+ completed, deep_dive_skipped, question_template_version, questions_json,
+ answers_json, created_at, updated_at
+)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ style_profile_id = excluded.style_profile_id,
+ persona_id = excluded.persona_id,
+ output_format_id = excluded.output_format_id,
+ parent_session_id = excluded.parent_session_id,
+ phase = excluded.phase,
+ completed = excluded.completed,
+ deep_dive_skipped = excluded.deep_dive_skipped,
+ question_template_version = excluded.question_template_version,
+ questions_json = excluded.questions_json,
+ answers_json = excluded.answers_json,
+ updated_at = excluded.updated_at`,
+ session.ID, session.StyleProfileID, session.PersonaID, session.OutputFormatID, session.ParentSessionID, string(session.Phase),
+ boolInt(session.Completed), boolInt(session.DeepDiveSkipped), defaultTemplateVersion, questionsJSON, answersJSON, formatTime(now), formatTime(now))
+ if err != nil {
+ return fmt.Errorf("save session: %w", err)
+ }
+ if _, err := tx.Exec(`DELETE FROM brief_answers WHERE session_id = ?`, session.ID); err != nil {
+ return fmt.Errorf("replace brief answers: %w", err)
+ }
+ for i, answer := range session.Answers {
+ answerJSON, err := marshalString(answer)
+ if err != nil {
+ return fmt.Errorf("encode brief answer %d: %w", i, err)
+ }
+ _, err = tx.Exec(`
+INSERT INTO brief_answers (
+ session_id, position, question_id, content, flow_type,
+ target_question_id, follow_up_index, parent_answer_id, answer_json
+)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ session.ID, i, answer.QuestionID, answer.Content, string(answer.FlowType), answer.TargetQuestionID, answer.FollowUpIndex, "", answerJSON)
+ if err != nil {
+ return fmt.Errorf("save brief answer %d: %w", i, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit save session: %w", err)
+ }
+ return nil
+}
+
+// GetSession returns a brief interview session by ID.
+func (s *WorkflowStore) GetSession(id string) (briefdomain.ArticleBriefSession, bool) {
+ var session briefdomain.ArticleBriefSession
+ var phase, questionsJSON, answersJSON string
+ var completed, deepDiveSkipped int
+ err := s.db.QueryRow(`
+SELECT id, style_profile_id, persona_id, output_format_id, parent_session_id, phase,
+ completed, deep_dive_skipped, questions_json, answers_json
+FROM brief_sessions
+WHERE id = ?`, id).Scan(&session.ID, &session.StyleProfileID, &session.PersonaID, &session.OutputFormatID, &session.ParentSessionID, &phase, &completed, &deepDiveSkipped, &questionsJSON, &answersJSON)
+ if err != nil {
+ return briefdomain.ArticleBriefSession{}, false
+ }
+ session.Phase = briefdomain.InterviewPhase(phase)
+ session.Completed = completed != 0
+ session.DeepDiveSkipped = deepDiveSkipped != 0
+ if err := unmarshalString(questionsJSON, &session.Questions); err != nil {
+ return briefdomain.ArticleBriefSession{}, false
+ }
+ if err := unmarshalString(answersJSON, &session.Answers); err != nil {
+ return briefdomain.ArticleBriefSession{}, false
+ }
+ return session, true
+}
+
+// SaveBrief stores the completed brief for a session.
+func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBrief) error {
+ if sessionID == "" {
+ return fmt.Errorf("brief session id is required")
+ }
+ if brief.StyleProfileID == "" {
+ return fmt.Errorf("brief style profile id is required")
+ }
+ briefJSON, err := marshalString(brief)
+ if err != nil {
+ return fmt.Errorf("encode brief: %w", err)
+ }
+ now := nowUTC()
+ _, err = s.db.Exec(`
+INSERT INTO briefs (session_id, style_profile_id, persona_id, output_format_id, brief_json, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(session_id) DO UPDATE SET
+ style_profile_id = excluded.style_profile_id,
+ persona_id = excluded.persona_id,
+ output_format_id = excluded.output_format_id,
+ brief_json = excluded.brief_json,
+ updated_at = excluded.updated_at`,
+ sessionID, brief.StyleProfileID, brief.PersonaID, brief.OutputFormatID, briefJSON, formatTime(now), formatTime(now))
+ if err != nil {
+ return fmt.Errorf("save brief: %w", err)
+ }
+ return nil
+}
+
+// GetBrief returns a completed brief by session ID.
+func (s *WorkflowStore) GetBrief(sessionID string) (briefdomain.ArticleBrief, bool) {
+ var brief briefdomain.ArticleBrief
+ var briefJSON string
+ err := s.db.QueryRow(`SELECT brief_json FROM briefs WHERE session_id = ?`, sessionID).Scan(&briefJSON)
+ if err != nil {
+ return briefdomain.ArticleBrief{}, false
+ }
+ if err := unmarshalString(briefJSON, &brief); err != nil {
+ return briefdomain.ArticleBrief{}, false
+ }
+ return brief, true
+}
+
+// SaveProject stores a project aggregate.
+func (s *WorkflowStore) SaveProject(project ProjectRecord) error {
+ if strings.TrimSpace(project.ID) == "" {
+ return fmt.Errorf("project id is required")
+ }
+ if strings.TrimSpace(project.Name) == "" {
+ return fmt.Errorf("project name is required")
+ }
+ project.CreatedAt = defaultTime(project.CreatedAt)
+ project.UpdatedAt = defaultTime(project.UpdatedAt)
+ metadataJSON, err := marshalString(nonNilMap(project.Metadata))
+ if err != nil {
+ return fmt.Errorf("encode project metadata: %w", err)
+ }
+ _, err = s.db.Exec(`
+INSERT INTO projects (id, name, created_at, updated_at, metadata_json)
+VALUES (?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ updated_at = excluded.updated_at,
+ metadata_json = excluded.metadata_json`,
+ project.ID, project.Name, formatTime(project.CreatedAt), formatTime(project.UpdatedAt), metadataJSON)
+ if err != nil {
+ return fmt.Errorf("save project: %w", err)
+ }
+ return nil
+}
+
+// GetProject returns a project by ID.
+func (s *WorkflowStore) GetProject(id string) (ProjectRecord, bool) {
+ var project ProjectRecord
+ var createdAt, updatedAt, metadataJSON string
+ err := s.db.QueryRow(`SELECT id, name, created_at, updated_at, metadata_json FROM projects WHERE id = ?`, id).
+ Scan(&project.ID, &project.Name, &createdAt, &updatedAt, &metadataJSON)
+ if err != nil {
+ return ProjectRecord{}, false
+ }
+ project.CreatedAt = parseTime(createdAt)
+ project.UpdatedAt = parseTime(updatedAt)
+ _ = unmarshalString(metadataJSON, &project.Metadata)
+ return project, true
+}
+
+// SaveArticle stores an article aggregate.
+func (s *WorkflowStore) SaveArticle(article ArticleRecord) error {
+ if strings.TrimSpace(article.ID) == "" {
+ return fmt.Errorf("article id is required")
+ }
+ if strings.TrimSpace(article.PersonaID) == "" {
+ return fmt.Errorf("article persona id is required")
+ }
+ if strings.TrimSpace(article.OutputFormatID) == "" {
+ return fmt.Errorf("article output format id is required")
+ }
+ article.CreatedAt = defaultTime(article.CreatedAt)
+ article.UpdatedAt = defaultTime(article.UpdatedAt)
+ metadataJSON, err := marshalString(nonNilMap(article.Metadata))
+ if err != nil {
+ return fmt.Errorf("encode article metadata: %w", err)
+ }
+ _, err = s.db.Exec(`
+INSERT INTO articles (
+ id, project_id, persona_id, output_format_id, brief_session_id,
+ current_draft_id, title, created_at, updated_at, metadata_json
+)
+VALUES (?, nullif(?, ''), ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ project_id = excluded.project_id,
+ persona_id = excluded.persona_id,
+ output_format_id = excluded.output_format_id,
+ brief_session_id = excluded.brief_session_id,
+ current_draft_id = excluded.current_draft_id,
+ title = excluded.title,
+ updated_at = excluded.updated_at,
+ metadata_json = excluded.metadata_json`,
+ article.ID, article.ProjectID, article.PersonaID, article.OutputFormatID, article.BriefSessionID, article.CurrentDraftID,
+ article.Title, formatTime(article.CreatedAt), formatTime(article.UpdatedAt), metadataJSON)
+ if err != nil {
+ return fmt.Errorf("save article: %w", err)
+ }
+ return nil
+}
+
+// GetArticle returns an article by ID.
+func (s *WorkflowStore) GetArticle(id string) (ArticleRecord, bool) {
+ var article ArticleRecord
+ var projectID, briefSessionID, currentDraftID sql.NullString
+ var createdAt, updatedAt, metadataJSON string
+ err := s.db.QueryRow(`
+SELECT id, project_id, persona_id, output_format_id, brief_session_id, current_draft_id,
+ title, created_at, updated_at, metadata_json
+FROM articles WHERE id = ?`, id).Scan(&article.ID, &projectID, &article.PersonaID, &article.OutputFormatID, &briefSessionID, ¤tDraftID, &article.Title, &createdAt, &updatedAt, &metadataJSON)
+ if err != nil {
+ return ArticleRecord{}, false
+ }
+ article.ProjectID = projectID.String
+ article.BriefSessionID = briefSessionID.String
+ article.CurrentDraftID = currentDraftID.String
+ article.CreatedAt = parseTime(createdAt)
+ article.UpdatedAt = parseTime(updatedAt)
+ _ = unmarshalString(metadataJSON, &article.Metadata)
+ return article, true
+}
+
+// SaveSourceSnapshot stores source selector and fetch snapshots.
+func (s *WorkflowStore) SaveSourceSnapshot(snapshot SourceSnapshotRecord) error {
+ if strings.TrimSpace(snapshot.ID) == "" {
+ return fmt.Errorf("source snapshot id is required")
+ }
+ if strings.TrimSpace(snapshot.ScopeType) == "" || strings.TrimSpace(snapshot.ScopeID) == "" {
+ return fmt.Errorf("source snapshot scope is required")
+ }
+ if err := snapshot.Selector.Validate(); err != nil {
+ return err
+ }
+ selectorJSON, err := marshalString(snapshot.Selector)
+ if err != nil {
+ return fmt.Errorf("encode selector: %w", err)
+ }
+ profileJSON, err := optionalMarshalString(snapshot.Profile)
+ if err != nil {
+ return fmt.Errorf("encode profile snapshot: %w", err)
+ }
+ articleJSON, err := optionalMarshalString(snapshot.Article)
+ if err != nil {
+ return fmt.Errorf("encode article snapshot: %w", err)
+ }
+ contentHash := snapshot.ContentHash
+ fetchedAt := snapshot.FetchedAt
+ if snapshot.Article != nil {
+ if contentHash == "" {
+ contentHash = hashString(snapshot.Article.Content)
+ }
+ if fetchedAt.IsZero() {
+ fetchedAt = snapshot.Article.FetchedAt
+ }
+ }
+ if contentHash == "" {
+ contentHash = hashString(selectorJSON + "\x00" + articleJSON)
+ }
+ if fetchedAt.IsZero() {
+ fetchedAt = nowUTC()
+ }
+ createdAt := defaultTime(snapshot.CreatedAt)
+ _, err = s.db.Exec(`
+INSERT INTO source_selector_snapshots (
+ id, scope_type, scope_id, selector_json, profile_json, article_json,
+ content_hash, fetched_at, created_at
+)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ scope_type = excluded.scope_type,
+ scope_id = excluded.scope_id,
+ selector_json = excluded.selector_json,
+ profile_json = excluded.profile_json,
+ article_json = excluded.article_json,
+ content_hash = excluded.content_hash,
+ fetched_at = excluded.fetched_at`,
+ snapshot.ID, snapshot.ScopeType, snapshot.ScopeID, selectorJSON, nullString(profileJSON), nullString(articleJSON), contentHash, formatTime(fetchedAt), formatTime(createdAt))
+ if err != nil {
+ return fmt.Errorf("save source snapshot: %w", err)
+ }
+ return nil
+}
+
+// ListSourceSnapshots returns source snapshots for a scope in insertion order.
+func (s *WorkflowStore) ListSourceSnapshots(scopeType, scopeID string) ([]SourceSnapshotRecord, error) {
+ rows, err := s.db.Query(`
+SELECT id, scope_type, scope_id, selector_json, profile_json, article_json, content_hash, fetched_at, created_at
+FROM source_selector_snapshots
+WHERE scope_type = ? AND scope_id = ?
+ORDER BY created_at, id`, scopeType, scopeID)
+ if err != nil {
+ return nil, fmt.Errorf("list source snapshots: %w", err)
+ }
+ defer rows.Close()
+ var records []SourceSnapshotRecord
+ for rows.Next() {
+ var record SourceSnapshotRecord
+ var selectorJSON, fetchedAt, createdAt string
+ var profileJSON, articleJSON sql.NullString
+ if err := rows.Scan(&record.ID, &record.ScopeType, &record.ScopeID, &selectorJSON, &profileJSON, &articleJSON, &record.ContentHash, &fetchedAt, &createdAt); err != nil {
+ return nil, fmt.Errorf("scan source snapshot: %w", err)
+ }
+ if err := unmarshalString(selectorJSON, &record.Selector); err != nil {
+ return nil, fmt.Errorf("decode selector %s: %w", record.ID, err)
+ }
+ if profileJSON.Valid {
+ var profile sourcedomain.ProfileSnapshot
+ if err := unmarshalString(profileJSON.String, &profile); err != nil {
+ return nil, fmt.Errorf("decode profile snapshot %s: %w", record.ID, err)
+ }
+ record.Profile = &profile
+ }
+ if articleJSON.Valid {
+ var article sourcedomain.ArticleSnapshot
+ if err := unmarshalString(articleJSON.String, &article); err != nil {
+ return nil, fmt.Errorf("decode article snapshot %s: %w", record.ID, err)
+ }
+ record.Article = &article
+ }
+ record.FetchedAt = parseTime(fetchedAt)
+ record.CreatedAt = parseTime(createdAt)
+ records = append(records, record)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate source snapshots: %w", err)
+ }
+ return records, nil
+}
+
+// SaveDraft stores a generated draft version with evaluation and verification metadata.
+func (s *WorkflowStore) SaveDraft(record DraftRecord) error {
+ if strings.TrimSpace(record.ID) == "" {
+ return fmt.Errorf("draft id is required")
+ }
+ if strings.TrimSpace(record.Markdown) == "" {
+ return fmt.Errorf("draft markdown is required")
+ }
+ if record.Version <= 0 {
+ return fmt.Errorf("draft version must be positive")
+ }
+ record.CreatedAt = defaultTime(record.CreatedAt)
+ if record.ContentHash == "" {
+ record.ContentHash = hashString(record.Markdown)
+ }
+ if record.QuestionTemplateVersion == "" {
+ record.QuestionTemplateVersion = defaultTemplateVersion
+ }
+ evaluationJSON, err := marshalString(record.Evaluation)
+ if err != nil {
+ return fmt.Errorf("encode draft evaluation: %w", err)
+ }
+ verificationJSON, err := marshalString(record.Verification)
+ if err != nil {
+ return fmt.Errorf("encode draft verification: %w", err)
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin save draft: %w", err)
+ }
+ defer rollbackUnlessDone(tx)
+ _, err = tx.Exec(`
+INSERT INTO drafts (
+ id, article_id, session_id, style_profile_id, persona_id, output_format_id,
+ version, markdown, content_hash, evaluation_json, verification_json,
+ question_template_version, created_at
+)
+VALUES (?, nullif(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ article_id = excluded.article_id,
+ session_id = excluded.session_id,
+ style_profile_id = excluded.style_profile_id,
+ persona_id = excluded.persona_id,
+ output_format_id = excluded.output_format_id,
+ version = excluded.version,
+ markdown = excluded.markdown,
+ content_hash = excluded.content_hash,
+ evaluation_json = excluded.evaluation_json,
+ verification_json = excluded.verification_json,
+ question_template_version = excluded.question_template_version`,
+ record.ID, record.ArticleID, record.SessionID, record.StyleProfileID, record.PersonaID, record.OutputFormatID, record.Version, record.Markdown, record.ContentHash,
+ evaluationJSON, verificationJSON, record.QuestionTemplateVersion, formatTime(record.CreatedAt))
+ if err != nil {
+ return fmt.Errorf("save draft: %w", err)
+ }
+ if record.ArticleID != "" {
+ if _, err := tx.Exec(`UPDATE articles SET current_draft_id = ?, updated_at = ? WHERE id = ?`, record.ID, formatTime(record.CreatedAt), record.ArticleID); err != nil {
+ return fmt.Errorf("update article current draft: %w", err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit save draft: %w", err)
+ }
+ return nil
+}
+
+// GetDraft returns a draft by ID.
+func (s *WorkflowStore) GetDraft(id string) (DraftRecord, bool) {
+ var record DraftRecord
+ var articleID sql.NullString
+ var evaluationJSON, verificationJSON, createdAt string
+ err := s.db.QueryRow(`
+SELECT id, article_id, session_id, style_profile_id, persona_id, output_format_id,
+ version, markdown, content_hash, evaluation_json, verification_json,
+ question_template_version, created_at
+FROM drafts WHERE id = ?`, id).Scan(&record.ID, &articleID, &record.SessionID, &record.StyleProfileID, &record.PersonaID, &record.OutputFormatID, &record.Version, &record.Markdown, &record.ContentHash, &evaluationJSON, &verificationJSON, &record.QuestionTemplateVersion, &createdAt)
+ if err != nil {
+ return DraftRecord{}, false
+ }
+ record.ArticleID = articleID.String
+ _ = unmarshalString(evaluationJSON, &record.Evaluation)
+ _ = unmarshalString(verificationJSON, &record.Verification)
+ record.CreatedAt = parseTime(createdAt)
+ return record, true
+}
+
+// ListDrafts returns all draft versions for an article in version order.
+func (s *WorkflowStore) ListDrafts(articleID string) ([]DraftRecord, error) {
+ rows, err := s.db.Query(`
+SELECT id FROM drafts
+WHERE article_id = ?
+ORDER BY version, created_at, id`, articleID)
+ if err != nil {
+ return nil, fmt.Errorf("list drafts: %w", err)
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan draft id: %w", err)
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate draft ids: %w", err)
+ }
+ records := make([]DraftRecord, 0, len(ids))
+ for _, id := range ids {
+ record, ok := s.GetDraft(id)
+ if !ok {
+ return nil, fmt.Errorf("draft %q disappeared while listing", id)
+ }
+ records = append(records, record)
+ }
+ return records, nil
+}
+
+// SaveSectionRegeneration stores a section-regeneration draft version.
+func (s *WorkflowStore) SaveSectionRegeneration(record SectionRegenerationRecord) error {
+ if strings.TrimSpace(record.ID) == "" {
+ return fmt.Errorf("section regeneration id is required")
+ }
+ if strings.TrimSpace(record.DraftID) == "" {
+ return fmt.Errorf("section regeneration draft id is required")
+ }
+ if strings.TrimSpace(record.SectionAnchor) == "" {
+ return fmt.Errorf("section regeneration anchor is required")
+ }
+ if record.BaseVersion <= 0 || record.Version <= 0 {
+ return fmt.Errorf("section regeneration versions must be positive")
+ }
+ if strings.TrimSpace(record.UpdatedDraftMarkdown) == "" {
+ return fmt.Errorf("updated draft markdown is required")
+ }
+ record.CreatedAt = defaultTime(record.CreatedAt)
+ if record.UpdatedContentHash == "" {
+ record.UpdatedContentHash = hashString(record.UpdatedDraftMarkdown)
+ }
+ verificationJSON, err := marshalString(record.Verification)
+ if err != nil {
+ return fmt.Errorf("encode section regeneration verification: %w", err)
+ }
+ _, err = s.db.Exec(`
+INSERT INTO section_regenerations (
+ id, draft_id, article_id, section_anchor, section_heading, base_version,
+ version, replacement_markdown, updated_draft_markdown, updated_content_hash,
+ verification_json, created_at
+)
+VALUES (?, ?, nullif(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+ draft_id = excluded.draft_id,
+ article_id = excluded.article_id,
+ section_anchor = excluded.section_anchor,
+ section_heading = excluded.section_heading,
+ base_version = excluded.base_version,
+ version = excluded.version,
+ replacement_markdown = excluded.replacement_markdown,
+ updated_draft_markdown = excluded.updated_draft_markdown,
+ updated_content_hash = excluded.updated_content_hash,
+ verification_json = excluded.verification_json`,
+ record.ID, record.DraftID, record.ArticleID, record.SectionAnchor, record.SectionHeading, record.BaseVersion, record.Version,
+ record.ReplacementMarkdown, record.UpdatedDraftMarkdown, record.UpdatedContentHash, verificationJSON, formatTime(record.CreatedAt))
+ if err != nil {
+ return fmt.Errorf("save section regeneration: %w", err)
+ }
+ return nil
+}
+
+// ListSectionRegenerations returns regenerations for a draft in version order.
+func (s *WorkflowStore) ListSectionRegenerations(draftID string) ([]SectionRegenerationRecord, error) {
+ rows, err := s.db.Query(`
+SELECT id, draft_id, article_id, section_anchor, section_heading, base_version,
+ version, replacement_markdown, updated_draft_markdown, updated_content_hash,
+ verification_json, created_at
+FROM section_regenerations
+WHERE draft_id = ?
+ORDER BY version, created_at, id`, draftID)
+ if err != nil {
+ return nil, fmt.Errorf("list section regenerations: %w", err)
+ }
+ defer rows.Close()
+ var records []SectionRegenerationRecord
+ for rows.Next() {
+ var record SectionRegenerationRecord
+ var articleID sql.NullString
+ var verificationJSON, createdAt string
+ if err := rows.Scan(&record.ID, &record.DraftID, &articleID, &record.SectionAnchor, &record.SectionHeading, &record.BaseVersion, &record.Version, &record.ReplacementMarkdown, &record.UpdatedDraftMarkdown, &record.UpdatedContentHash, &verificationJSON, &createdAt); err != nil {
+ return nil, fmt.Errorf("scan section regeneration: %w", err)
+ }
+ record.ArticleID = articleID.String
+ _ = unmarshalString(verificationJSON, &record.Verification)
+ record.CreatedAt = parseTime(createdAt)
+ records = append(records, record)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate section regenerations: %w", err)
+ }
+ return records, nil
+}
+
+func migrationVersion(name string) (int, error) {
+ prefix := strings.SplitN(name, "_", 2)[0]
+ version, err := strconv.Atoi(prefix)
+ if err != nil {
+ return 0, fmt.Errorf("invalid migration filename %q: %w", name, err)
+ }
+ return version, nil
+}
+
+func rollbackUnlessDone(tx *sql.Tx) {
+ _ = tx.Rollback()
+}
+
+func marshalString(value any) (string, error) {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return "", err
+ }
+ return string(encoded), nil
+}
+
+func optionalMarshalString[T any](value *T) (string, error) {
+ if value == nil {
+ return "", nil
+ }
+ return marshalString(value)
+}
+
+func unmarshalString(encoded string, out any) error {
+ return json.Unmarshal([]byte(encoded), out)
+}
+
+func nullString(value string) sql.NullString {
+ if value == "" {
+ return sql.NullString{}
+ }
+ return sql.NullString{String: value, Valid: true}
+}
+
+func boolInt(value bool) int {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func nowUTC() time.Time {
+ return time.Now().UTC().Round(0)
+}
+
+func defaultTime(value time.Time) time.Time {
+ if value.IsZero() {
+ return nowUTC()
+ }
+ return value.UTC().Round(0)
+}
+
+func formatTime(value time.Time) string {
+ return defaultTime(value).Format(time.RFC3339Nano)
+}
+
+func parseTime(value string) time.Time {
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return time.Time{}
+ }
+ return parsed
+}
+
+func hashString(value string) string {
+ sum := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(sum[:])
+}
+
+func nonNilMap(value map[string]any) map[string]any {
+ if value == nil {
+ return map[string]any{}
+ }
+ return value
+}
diff --git a/internal/infrastructure/repository/sqlite/workflow_test.go b/internal/infrastructure/repository/sqlite/workflow_test.go
new file mode 100644
index 0000000..00ea232
--- /dev/null
+++ b/internal/infrastructure/repository/sqlite/workflow_test.go
@@ -0,0 +1,299 @@
+package sqlite
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ authorstyleapp "github.com/teradakousuke/note_maker/internal/application/authorstyle"
+ draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ 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"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+)
+
+func TestWorkflowStoreRestoresDraftInputs(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "note_maker.db")
+ store, err := NewWorkflowStore(path)
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+
+ result := testAnalyzeResult(t)
+ if err := store.SaveAuthorStyle(result); err != nil {
+ t.Fatalf("save author style: %v", err)
+ }
+ session := testCompletedSession(t, result.Profile.ID)
+ if err := store.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ brief := session.AssembleBrief()
+ if err := store.SaveBrief(session.ID, brief); err != nil {
+ t.Fatalf("save brief: %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() })
+ for _, id := range []string{result.ID, result.Profile.ID, result.Guide.ID} {
+ profile, guide, ok := reopened.GetProfileAndGuide(id)
+ if !ok {
+ t.Fatalf("expected profile and guide for id %q after reopen", id)
+ }
+ if profile.ID != result.Profile.ID || guide.ID != result.Guide.ID {
+ t.Fatalf("restored wrong style assets: profile=%s guide=%s", profile.ID, guide.ID)
+ }
+ }
+ restoredSession, ok := reopened.GetSession(session.ID)
+ if !ok {
+ t.Fatal("expected session after reopen")
+ }
+ if restoredSession.PersonaID != personadomain.IDTerisuke || restoredSession.OutputFormatID != outputformat.IDNoteArticle {
+ t.Fatalf("session mode was not restored: %#v", restoredSession)
+ }
+ if len(restoredSession.Answers) != len(session.Answers) {
+ t.Fatalf("answers = %d, want %d", len(restoredSession.Answers), len(session.Answers))
+ }
+ restoredBrief, ok := reopened.GetBrief(session.ID)
+ if !ok {
+ t.Fatal("expected brief after reopen")
+ }
+ if restoredBrief.PersonalContext == "" || len(restoredBrief.DeepDives) != 1 || len(restoredBrief.CustomAnswers) != 1 {
+ t.Fatalf("brief did not preserve generation context: %#v", restoredBrief)
+ }
+}
+
+func TestWorkflowStoreAppliesSchemaMigrations(t *testing.T) {
+ store, err := NewWorkflowStore(filepath.Join(t.TempDir(), "note_maker.db"))
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+
+ var migrationCount int
+ if err := store.DB().QueryRow(`SELECT count(*) FROM schema_migrations WHERE version = 1`).Scan(&migrationCount); err != nil {
+ t.Fatalf("query schema migrations: %v", err)
+ }
+ 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"} {
+ 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)
+ }
+ }
+}
+
+func TestWorkflowStorePersistsHistoryRecords(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "note_maker.db")
+ store, err := NewWorkflowStore(path)
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ now := time.Unix(1710000000, 0).UTC()
+
+ project := ProjectRecord{
+ ID: "project_media_matrix",
+ Name: "Media matrix",
+ CreatedAt: now,
+ UpdatedAt: now,
+ Metadata: map[string]any{"owner": "scenario"},
+ }
+ if err := store.SaveProject(project); err != nil {
+ t.Fatalf("save project: %v", err)
+ }
+ article := ArticleRecord{
+ ID: "article_zenn",
+ ProjectID: project.ID,
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ BriefSessionID: "brief_zenn",
+ Title: "SQLite-backed history",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := store.SaveArticle(article); err != nil {
+ t.Fatalf("save article: %v", err)
+ }
+ sourceArticle := sourcedomain.ArticleSnapshot{
+ ID: "source-1",
+ Kind: sourcedomain.KindZenn,
+ URL: "https://zenn.dev/cloudia/articles/sqlite-history",
+ Title: "SQLite history",
+ Content: "source body",
+ FetchedAt: now,
+ }
+ if err := store.SaveSourceSnapshot(SourceSnapshotRecord{
+ ID: "snapshot-1",
+ ScopeType: "article",
+ ScopeID: article.ID,
+ Selector: sourcedomain.Ref{
+ Kind: sourcedomain.KindZenn,
+ Ref: "cloudia",
+ },
+ Article: &sourceArticle,
+ FetchedAt: now,
+ CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("save source snapshot: %v", err)
+ }
+ draftRecord := DraftRecord{
+ ID: "draft-1",
+ ArticleID: article.ID,
+ SessionID: article.BriefSessionID,
+ StyleProfileID: "profile-1",
+ PersonaID: article.PersonaID,
+ OutputFormatID: article.OutputFormatID,
+ Version: 1,
+ Markdown: "---\ntitle: \"SQLite\"\nemoji: \"🧪\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n## 実装\n\n本文です。",
+ Evaluation: draftapp.StyleEvaluation{
+ Passed: true,
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "ok",
+ },
+ CreatedAt: now,
+ }
+ if err := store.SaveDraft(draftRecord); err != nil {
+ t.Fatalf("save draft: %v", err)
+ }
+ if err := store.SaveSectionRegeneration(SectionRegenerationRecord{
+ ID: "regen-1",
+ DraftID: draftRecord.ID,
+ ArticleID: article.ID,
+ SectionAnchor: "implementation",
+ SectionHeading: "実装",
+ BaseVersion: 1,
+ Version: 2,
+ ReplacementMarkdown: "## 実装\n\n更新後です。",
+ UpdatedDraftMarkdown: "---\ntitle: \"SQLite\"\nemoji: \"🧪\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n## 実装\n\n更新後です。",
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "regenerated ok",
+ },
+ CreatedAt: now.Add(time.Minute),
+ }); err != nil {
+ t.Fatalf("save section regeneration: %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() })
+ restoredProject, ok := reopened.GetProject(project.ID)
+ if !ok || restoredProject.Name != project.Name || restoredProject.Metadata["owner"] != "scenario" {
+ t.Fatalf("unexpected restored project: %#v ok=%v", restoredProject, ok)
+ }
+ restoredArticle, ok := reopened.GetArticle(article.ID)
+ if !ok || restoredArticle.CurrentDraftID != draftRecord.ID {
+ t.Fatalf("unexpected restored article: %#v ok=%v", restoredArticle, ok)
+ }
+ snapshots, err := reopened.ListSourceSnapshots("article", article.ID)
+ if err != nil {
+ t.Fatalf("list source snapshots: %v", err)
+ }
+ if len(snapshots) != 1 || snapshots[0].ContentHash == "" || snapshots[0].Article.Content != sourceArticle.Content {
+ t.Fatalf("unexpected snapshots: %#v", snapshots)
+ }
+ drafts, err := reopened.ListDrafts(article.ID)
+ if err != nil {
+ t.Fatalf("list drafts: %v", err)
+ }
+ if len(drafts) != 1 || drafts[0].ContentHash == "" || !drafts[0].Verification.Passed || drafts[0].QuestionTemplateVersion == "" {
+ t.Fatalf("unexpected drafts: %#v", drafts)
+ }
+ regenerations, err := reopened.ListSectionRegenerations(draftRecord.ID)
+ if err != nil {
+ t.Fatalf("list regenerations: %v", err)
+ }
+ if len(regenerations) != 1 || regenerations[0].Version != 2 || !strings.Contains(regenerations[0].UpdatedDraftMarkdown, "更新後") {
+ t.Fatalf("unexpected regenerations: %#v", regenerations)
+ }
+}
+
+func testAnalyzeResult(t *testing.T) authorstyleapp.AnalyzeResult {
+ t.Helper()
+ fetchedAt := time.Unix(1700000000, 0).UTC()
+ article := articledomain.Article{
+ URL: "https://note.com/tera/n/n111",
+ Title: "AIと音楽",
+ Content: "僕はAIと音楽の違和感を言語化する。\n\n「これは大事だ」と思った。",
+ }
+ source := authordomain.AuthorSource{
+ Username: "tera",
+ Articles: authordomain.SourceArticlesFromArticles([]articledomain.Article{article}, fetchedAt),
+ FetchedAt: fetchedAt,
+ }
+ profile, err := authordomain.BuildAuthorStyleProfile(source, []articledomain.Article{article})
+ if err != nil {
+ t.Fatalf("build profile: %v", err)
+ }
+ guide, err := authordomain.BuildWritingStyleGuide(profile)
+ if err != nil {
+ t.Fatalf("build guide: %v", err)
+ }
+ return authorstyleapp.AnalyzeResult{
+ ID: "asr_test",
+ Source: source,
+ Profile: profile,
+ Guide: guide,
+ ArticleCount: 1,
+ CreatedAt: fetchedAt,
+ }
+}
+
+func testCompletedSession(t *testing.T, profileID string) briefdomain.ArticleBriefSession {
+ t.Helper()
+ questions := append(briefdomain.FixedQuestions(), briefdomain.ArticleQuestion{
+ ID: "custom_origin",
+ Text: "どの原体験を入れますか?",
+ FlowType: briefdomain.QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ })
+ session, err := briefdomain.NewArticleBriefSessionWithQuestions("brief_test", profileID, questions)
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ answers := map[string]string{
+ briefdomain.QuestionIDTheme: "ローカルLLMで思想を言語化する",
+ briefdomain.QuestionIDOpeningEpisode: "生成文を読んで自分の切実さが抜けていた",
+ briefdomain.QuestionIDReader: "AIで発信したい個人開発者",
+ briefdomain.QuestionIDExpectedReaderAction: "AIに取材させる視点を持つ",
+ briefdomain.QuestionIDMustInclude: "Note API、文体ガイド、一問一答、深掘り",
+ briefdomain.QuestionIDPersonalContext: "音楽家、エンジニア、起業、LT登壇の経験",
+ briefdomain.QuestionIDExclusions: "根拠のない性能断言",
+ briefdomain.QuestionIDTargetLengthStructure: "3000字前後、最低2800字",
+ briefdomain.QuestionIDToneStance: "内省と技術検証を両立する",
+ "custom_origin": "音楽の練習で身体化した感覚",
+ }
+ for _, question := range questions {
+ if _, err := session.RecordAnswer(answers[question.ID]); err != nil {
+ t.Fatalf("answer %s: %v", question.ID, err)
+ }
+ }
+ if _, err := session.RecordAnswer("自分の言葉を失う怖さを最初に見せる"); err != nil {
+ t.Fatalf("answer deep dive: %v", err)
+ }
+ if _, err := session.Complete(); err != nil {
+ t.Fatalf("complete: %v", err)
+ }
+ return session
+}
From d8f4adb1fad2909078edf406eb19f802ed27a4fa Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 00:56:10 +0900
Subject: [PATCH 19/33] feat: expose storage mode in settings
---
README.md | 4 +-
cmd/server/main.go | 2 +
internal/handlers/config.go | 209 +++++++++++++++++++++++++++++++
internal/handlers/config_test.go | 111 ++++++++++++++++
internal/handlers/workflow.go | 16 +--
static/css/style.css | 38 ++++++
static/index.html | 20 +++
static/js/script.js | 76 +++++++++++
8 files changed, 464 insertions(+), 12 deletions(-)
create mode 100644 internal/handlers/config.go
create mode 100644 internal/handlers/config_test.go
diff --git a/README.md b/README.md
index 2580501..c147d2f 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,9 @@ mise run evo-x2
文体分析結果、取材セッションの回答、完成ブリーフは `WORKFLOW_STORE_PATH` にJSONとして永続化されます。既定値は `data/workflow_store.json` です。
-SQLiteを試す場合は `WORKFLOW_STORE_DRIVER=sqlite` を指定します。既定パスは `data/workflow_store.db` で、`WORKFLOW_STORE_PATH` で変更できます。JSON store は互換性のため既定のまま残しています。
+保存方式は設定画面の「保存方式」から選べます。UIで変更した内容は `data/app_config.json` に保存され、サーバー再起動後に反映されます。SQLiteを選んだ場合の既定パスは `data/workflow_store.db` です。JSON store は互換性のため既定のまま残しています。
+
+開発・検証で強制したい場合は `WORKFLOW_STORE_DRIVER=sqlite` を指定できます。この環境変数がある場合、設定画面では保存方式がロック表示になります。
フェーズ別モデルの目安:
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 70329c0..e69a362 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -32,6 +32,8 @@ func main() {
// APIエンドポイントの設定
r.HandleFunc("/api/generate", handlers.GenerateArticleHandler).Methods("POST")
r.HandleFunc("/api/models", handlers.ListModelsHandler).Methods("GET")
+ 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/formats", handlers.ListFormatsHandler).Methods("GET")
r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST")
diff --git a/internal/handlers/config.go b/internal/handlers/config.go
new file mode 100644
index 0000000..d163db8
--- /dev/null
+++ b/internal/handlers/config.go
@@ -0,0 +1,209 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+const (
+ storageDriverJSON = "json"
+ storageDriverSQLite = "sqlite"
+)
+
+var activeWorkflowStorage = workflowStorageConfig{
+ Driver: storageDriverJSON,
+ Path: "data/workflow_store.json",
+ Source: "default",
+}
+
+var workflowStorageMu sync.RWMutex
+
+type workflowStorageConfig struct {
+ Driver string
+ Path string
+ Source string
+}
+
+type persistedAppConfig struct {
+ WorkflowStoreDriver string `json:"workflow_store_driver"`
+ WorkflowStorePath string `json:"workflow_store_path"`
+}
+
+type storageConfigRequest struct {
+ WorkflowStoreDriver string `json:"workflow_store_driver"`
+ WorkflowStorePath string `json:"workflow_store_path"`
+}
+
+type storageConfigResponse struct {
+ ActiveDriver string `json:"active_driver"`
+ ActivePath string `json:"active_path"`
+ ConfiguredDriver string `json:"configured_driver"`
+ ConfiguredPath string `json:"configured_path"`
+ ConfigPath string `json:"config_path"`
+ EnvLocked bool `json:"env_locked"`
+ RestartRequired bool `json:"restart_required"`
+ RestartMessage string `json:"restart_message"`
+ EffectiveNextBoot bool `json:"effective_next_boot"`
+}
+
+// GetStorageConfigHandler returns the current and next-boot workflow storage config.
+func GetStorageConfigHandler(w http.ResponseWriter, r *http.Request) {
+ respondWithJSON(w, http.StatusOK, currentStorageConfigResponse())
+}
+
+// UpdateStorageConfigHandler writes the next-boot workflow storage config.
+func UpdateStorageConfigHandler(w http.ResponseWriter, r *http.Request) {
+ if storageEnvLocked() {
+ respondWithError(w, "STORAGE_CONFIG_LOCKED", "Storage config is locked by environment variables", "", http.StatusConflict)
+ return
+ }
+ var req storageConfigRequest
+ if err := decodeJSONRequest(r, &req); err != nil {
+ respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest)
+ return
+ }
+ driver, path, err := normalizeWorkflowStorage(req.WorkflowStoreDriver, req.WorkflowStorePath)
+ if err != nil {
+ respondWithError(w, "INVALID_STORAGE_CONFIG", "Invalid storage config", err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := writePersistedAppConfig(persistedAppConfig{
+ WorkflowStoreDriver: driver,
+ WorkflowStorePath: path,
+ }); err != nil {
+ respondWithError(w, "STORAGE_CONFIG_SAVE_FAILED", "Failed to save storage config", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, currentStorageConfigResponse())
+}
+
+func currentStorageConfigResponse() storageConfigResponse {
+ active := getActiveWorkflowStorage()
+ configured := resolveNextBootWorkflowStorage()
+ restartRequired := active.Driver != configured.Driver || active.Path != configured.Path
+ message := "現在の保存先で動作中です。"
+ if restartRequired {
+ message = "保存方式の変更はサーバー再起動後に反映されます。"
+ }
+ return storageConfigResponse{
+ ActiveDriver: active.Driver,
+ ActivePath: active.Path,
+ ConfiguredDriver: configured.Driver,
+ ConfiguredPath: configured.Path,
+ ConfigPath: appConfigPath(),
+ EnvLocked: storageEnvLocked(),
+ RestartRequired: restartRequired,
+ RestartMessage: message,
+ EffectiveNextBoot: !restartRequired,
+ }
+}
+
+func resolveWorkflowStorageConfig() workflowStorageConfig {
+ if driver := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_DRIVER")); driver != "" {
+ path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
+ normalizedDriver, normalizedPath, err := normalizeWorkflowStorage(driver, path)
+ if err != nil {
+ return workflowStorageConfig{Driver: driver, Path: path, Source: "env-invalid"}
+ }
+ return workflowStorageConfig{Driver: normalizedDriver, Path: normalizedPath, Source: "env"}
+ }
+ configured := resolveNextBootWorkflowStorage()
+ if configured.Source == "default" && strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH")) != "" {
+ path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
+ _, normalizedPath, err := normalizeWorkflowStorage(storageDriverJSON, path)
+ if err != nil {
+ return workflowStorageConfig{Driver: storageDriverJSON, Path: path, Source: "env-path-invalid"}
+ }
+ return workflowStorageConfig{Driver: storageDriverJSON, Path: normalizedPath, Source: "env-path"}
+ }
+ return configured
+}
+
+func resolveNextBootWorkflowStorage() workflowStorageConfig {
+ config, ok := readPersistedAppConfig()
+ if !ok {
+ return workflowStorageConfig{Driver: storageDriverJSON, Path: "data/workflow_store.json", Source: "default"}
+ }
+ driver, path, err := normalizeWorkflowStorage(config.WorkflowStoreDriver, config.WorkflowStorePath)
+ if err != nil {
+ return workflowStorageConfig{Driver: storageDriverJSON, Path: "data/workflow_store.json", Source: "config-invalid"}
+ }
+ return workflowStorageConfig{Driver: driver, Path: path, Source: "config"}
+}
+
+func normalizeWorkflowStorage(driver, path string) (string, string, error) {
+ driver = strings.ToLower(strings.TrimSpace(driver))
+ switch driver {
+ case "", storageDriverJSON:
+ driver = storageDriverJSON
+ if strings.TrimSpace(path) == "" {
+ path = "data/workflow_store.json"
+ }
+ case storageDriverSQLite:
+ if strings.TrimSpace(path) == "" {
+ path = "data/workflow_store.db"
+ }
+ default:
+ return "", "", fmt.Errorf("workflow_store_driver must be json or sqlite")
+ }
+ return driver, filepath.Clean(strings.TrimSpace(path)), nil
+}
+
+func setActiveWorkflowStorage(config workflowStorageConfig) {
+ workflowStorageMu.Lock()
+ defer workflowStorageMu.Unlock()
+ activeWorkflowStorage = config
+}
+
+func getActiveWorkflowStorage() workflowStorageConfig {
+ workflowStorageMu.RLock()
+ defer workflowStorageMu.RUnlock()
+ return activeWorkflowStorage
+}
+
+func storageEnvLocked() bool {
+ return strings.TrimSpace(os.Getenv("WORKFLOW_STORE_DRIVER")) != ""
+}
+
+func appConfigPath() string {
+ if path := strings.TrimSpace(os.Getenv("NOTE_MAKER_CONFIG_PATH")); path != "" {
+ return filepath.Clean(path)
+ }
+ return "data/app_config.json"
+}
+
+func readPersistedAppConfig() (persistedAppConfig, bool) {
+ encoded, err := os.ReadFile(appConfigPath())
+ if err != nil {
+ return persistedAppConfig{}, false
+ }
+ var config persistedAppConfig
+ if err := json.Unmarshal(encoded, &config); err != nil {
+ return persistedAppConfig{}, false
+ }
+ return config, true
+}
+
+func writePersistedAppConfig(config persistedAppConfig) error {
+ path := appConfigPath()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("create app config dir: %w", err)
+ }
+ encoded, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode app config: %w", err)
+ }
+ tempPath := path + ".tmp"
+ if err := os.WriteFile(tempPath, append(encoded, '\n'), 0o600); err != nil {
+ return fmt.Errorf("write app config temp: %w", err)
+ }
+ if err := os.Rename(tempPath, path); err != nil {
+ return fmt.Errorf("replace app config: %w", err)
+ }
+ return nil
+}
diff --git a/internal/handlers/config_test.go b/internal/handlers/config_test.go
new file mode 100644
index 0000000..9e7b68f
--- /dev/null
+++ b/internal/handlers/config_test.go
@@ -0,0 +1,111 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestStorageConfigHandlersPersistNextBootSQLite(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "app_config.json")
+ t.Setenv("NOTE_MAKER_CONFIG_PATH", configPath)
+ t.Setenv("WORKFLOW_STORE_PATH", "")
+ setActiveWorkflowStorage(workflowStorageConfig{Driver: storageDriverJSON, Path: "data/workflow_store.json", Source: "test"})
+
+ body := `{"workflow_store_driver":"sqlite","workflow_store_path":"data/custom.db"}`
+ request := httptest.NewRequest(http.MethodPatch, "/api/config/storage", strings.NewReader(body))
+ response := httptest.NewRecorder()
+
+ UpdateStorageConfigHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload storageConfigResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.ConfiguredDriver != storageDriverSQLite || payload.ConfiguredPath != "data/custom.db" {
+ t.Fatalf("unexpected configured storage: %#v", payload)
+ }
+ if !payload.RestartRequired {
+ t.Fatalf("restart_required = false, payload = %#v", payload)
+ }
+
+ getResponse := httptest.NewRecorder()
+ GetStorageConfigHandler(getResponse, httptest.NewRequest(http.MethodGet, "/api/config/storage", nil))
+ if getResponse.Code != http.StatusOK {
+ t.Fatalf("get status = %d, body = %s", getResponse.Code, getResponse.Body.String())
+ }
+ var fetched storageConfigResponse
+ if err := json.NewDecoder(getResponse.Body).Decode(&fetched); err != nil {
+ t.Fatalf("decode get response: %v", err)
+ }
+ if fetched.ConfiguredDriver != storageDriverSQLite || fetched.ConfiguredPath != "data/custom.db" {
+ t.Fatalf("unexpected fetched storage: %#v", fetched)
+ }
+}
+
+func TestStorageConfigHandlerRejectsEnvLockedUpdate(t *testing.T) {
+ t.Setenv("WORKFLOW_STORE_DRIVER", "sqlite")
+ request := httptest.NewRequest(http.MethodPatch, "/api/config/storage", strings.NewReader(`{"workflow_store_driver":"json"}`))
+ response := httptest.NewRecorder()
+
+ UpdateStorageConfigHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusConflict, "STORAGE_CONFIG_LOCKED")
+}
+
+func TestStorageConfigHandlerRejectsInvalidDriver(t *testing.T) {
+ t.Setenv("WORKFLOW_STORE_DRIVER", "")
+ request := httptest.NewRequest(http.MethodPatch, "/api/config/storage", strings.NewReader(`{"workflow_store_driver":"mysql"}`))
+ response := httptest.NewRecorder()
+
+ UpdateStorageConfigHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusBadRequest, "INVALID_STORAGE_CONFIG")
+}
+
+func TestResolveWorkflowStorageConfigUsesEnvironment(t *testing.T) {
+ t.Setenv("WORKFLOW_STORE_DRIVER", "sqlite")
+ t.Setenv("WORKFLOW_STORE_PATH", "data/env.db")
+
+ config := resolveWorkflowStorageConfig()
+
+ if config.Driver != storageDriverSQLite || config.Path != "data/env.db" || config.Source != "env" {
+ t.Fatalf("unexpected env config: %#v", config)
+ }
+}
+
+func TestResolveWorkflowStorageConfigUsesJSONPathEnvironment(t *testing.T) {
+ t.Setenv("WORKFLOW_STORE_DRIVER", "")
+ t.Setenv("WORKFLOW_STORE_PATH", "data/env.json")
+ t.Setenv("NOTE_MAKER_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json"))
+
+ config := resolveWorkflowStorageConfig()
+
+ if config.Driver != storageDriverJSON || config.Path != "data/env.json" || config.Source != "env-path" {
+ t.Fatalf("unexpected env path config: %#v", config)
+ }
+}
+
+func TestNormalizeWorkflowStorageDefaultsPaths(t *testing.T) {
+ driver, path, err := normalizeWorkflowStorage("", "")
+ if err != nil {
+ t.Fatalf("normalize json: %v", err)
+ }
+ if driver != storageDriverJSON || path != "data/workflow_store.json" {
+ t.Fatalf("json default = %s %s", driver, path)
+ }
+
+ driver, path, err = normalizeWorkflowStorage("sqlite", "")
+ if err != nil {
+ t.Fatalf("normalize sqlite: %v", err)
+ }
+ if driver != storageDriverSQLite || path != "data/workflow_store.db" {
+ t.Fatalf("sqlite default = %s %s", driver, path)
+ }
+}
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index 17ddcc4..1f00312 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
"strings"
"sync"
"time"
@@ -40,22 +39,17 @@ type workflowStoreBackend interface {
}
func newWorkflowStore() workflowStoreBackend {
- driver := strings.ToLower(strings.TrimSpace(os.Getenv("WORKFLOW_STORE_DRIVER")))
- if driver == "sqlite" {
- path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
- if path == "" {
- path = "data/workflow_store.db"
- }
+ config := resolveWorkflowStorageConfig()
+ setActiveWorkflowStorage(config)
+ if config.Driver == storageDriverSQLite {
+ path := config.Path
store, err := sqliterepo.NewWorkflowStore(path)
if err == nil {
return store
}
panic(fmt.Sprintf("initialize sqlite workflow store: %v", err))
}
- path := strings.TrimSpace(os.Getenv("WORKFLOW_STORE_PATH"))
- if path == "" {
- path = "data/workflow_store.json"
- }
+ path := config.Path
store, err := memory.NewPersistentWorkflowStore(path)
if err == nil {
return store
diff --git a/static/css/style.css b/static/css/style.css
index d4905ca..6953626 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -137,6 +137,44 @@ body {
font-size: 14px;
}
+.storage-config {
+ margin: 4px 0 14px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fbfcfe;
+}
+
+.storage-config .section-heading {
+ margin-top: 0;
+}
+
+.storage-path-field {
+ grid-column: span 2;
+}
+
+.storage-summary {
+ display: grid;
+ gap: 4px;
+ margin-top: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--muted);
+ background: var(--surface);
+ font-size: 14px;
+}
+
+.storage-summary.warning {
+ border-color: #fde68a;
+ color: var(--warning);
+ background: var(--warning-bg);
+}
+
+.storage-summary strong {
+ color: var(--text);
+}
+
.mode-summary strong {
color: var(--text);
}
diff --git a/static/index.html b/static/index.html
index 76ff170..8efa8f6 100644
--- a/static/index.html
+++ b/static/index.html
@@ -55,6 +55,26 @@ 設定
+
+
+
保存方式
+ 保存方式を保存
+
+
+
+ 保存先
+
+ JSONファイル
+ SQLite
+
+
+
+ 保存パス
+
+
+
+
+
diff --git a/static/js/script.js b/static/js/script.js
index aeac617..8d24d85 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -43,6 +43,7 @@ document.addEventListener('DOMContentLoaded', () => {
templateLoading: false,
templateError: '',
templateRequestId: 0,
+ storageConfig: null,
questionTextById: {},
lastSubmittedAnswer: '',
answerAbortController: null,
@@ -59,6 +60,10 @@ document.addEventListener('DOMContentLoaded', () => {
briefModel: document.getElementById('brief-model'),
draftModel: document.getElementById('draft-model'),
verifyModel: document.getElementById('verify-model'),
+ storageDriver: document.getElementById('storage-driver'),
+ storagePath: document.getElementById('storage-path'),
+ saveStorage: document.getElementById('save-storage-btn'),
+ storageSummary: document.getElementById('storage-summary'),
questionConfigList: document.getElementById('question-config-list'),
addQuestion: document.getElementById('add-question-btn'),
resetQuestions: document.getElementById('reset-questions-btn'),
@@ -104,6 +109,7 @@ document.addEventListener('DOMContentLoaded', () => {
renderQuestionConfig();
initializeModeControls();
checkModels();
+ loadStorageConfig();
el.personaSelect.addEventListener('change', onPersonaChange);
el.formatSelect.addEventListener('change', onFormatChange);
@@ -111,6 +117,8 @@ document.addEventListener('DOMContentLoaded', () => {
el.briefModel.addEventListener('change', saveModelConfig);
el.draftModel.addEventListener('change', saveModelConfig);
el.verifyModel.addEventListener('change', saveModelConfig);
+ el.storageDriver.addEventListener('change', onStorageDriverChange);
+ el.saveStorage.addEventListener('click', saveStorageConfig);
el.addQuestion.addEventListener('click', addQuestion);
el.resetQuestions.addEventListener('click', resetQuestions);
el.analyzeStyle.addEventListener('click', analyzeStyle);
@@ -761,6 +769,74 @@ document.addEventListener('DOMContentLoaded', () => {
saveConfig();
}
+ async function loadStorageConfig() {
+ try {
+ const data = await requestJSON('/api/config/storage');
+ state.storageConfig = data;
+ applyStorageConfig(data);
+ } catch (error) {
+ el.storageSummary.className = 'storage-summary warning';
+ el.storageSummary.textContent = `保存方式を取得できませんでした: ${error.message}`;
+ }
+ }
+
+ function applyStorageConfig(data) {
+ el.storageDriver.value = data.configured_driver || data.active_driver || 'json';
+ el.storagePath.value = data.configured_path || data.active_path || defaultStoragePath(el.storageDriver.value);
+ el.storageDriver.disabled = Boolean(data.env_locked);
+ el.storagePath.disabled = Boolean(data.env_locked);
+ el.saveStorage.disabled = Boolean(data.env_locked);
+ renderStorageSummary(data);
+ }
+
+ function renderStorageSummary(data) {
+ const active = `${storageDriverLabel(data.active_driver)} / ${data.active_path}`;
+ const configured = `${storageDriverLabel(data.configured_driver)} / ${data.configured_path}`;
+ const status = data.env_locked
+ ? '環境変数で固定されています。UIからは変更できません。'
+ : data.restart_required
+ ? data.restart_message
+ : '現在の保存方式で動作中です。';
+ el.storageSummary.className = `storage-summary${data.restart_required ? ' warning' : ''}`;
+ el.storageSummary.innerHTML = `
+ 現在: ${escapeHTML(active)}
+ 次回起動: ${escapeHTML(configured)}
+ ${escapeHTML(status)}
+ `;
+ }
+
+ function onStorageDriverChange() {
+ const currentPath = el.storagePath.value.trim();
+ if (!currentPath || currentPath === defaultStoragePath('json') || currentPath === defaultStoragePath('sqlite')) {
+ el.storagePath.value = defaultStoragePath(el.storageDriver.value);
+ }
+ }
+
+ async function saveStorageConfig() {
+ clearError();
+ try {
+ const data = await requestJSON('/api/config/storage', {
+ method: 'PATCH',
+ body: {
+ workflow_store_driver: el.storageDriver.value,
+ workflow_store_path: el.storagePath.value,
+ },
+ });
+ state.storageConfig = data;
+ applyStorageConfig(data);
+ } catch (error) {
+ showError(`保存方式の保存に失敗しました: ${error.message}`);
+ }
+ }
+
+ function storageDriverLabel(driver) {
+ return driver === 'sqlite' ? 'SQLite' : 'JSONファイル';
+ }
+
+ function defaultStoragePath(driver) {
+ return driver === 'sqlite' ? 'data/workflow_store.db' : 'data/workflow_store.json';
+ }
+
async function loadQuestionTemplate() {
const personaId = currentPersonaId();
const formatId = currentFormatId();
From b4d8befd0075169414cd8e9ae57db1012f175023 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 01:21:53 +0900
Subject: [PATCH 20/33] Fix Evo X2 runtime defaults and audit docs (#65)
---
Makefile | 6 +-
cmd/server/main.go | 3 +
...02-multi-persona-multi-format-extension.md | 14 ++--
.../issue-adr-guardrails.md | 4 +-
.../next-implementation-cut.md | 10 +--
.../runtime-ui-ddd-audit-2026-05-03.md | 79 +++++++++++++++++++
internal/infrastructure/llamacpp/client.go | 31 ++++++--
.../infrastructure/llamacpp/client_test.go | 48 +++++++++++
scripts/dev.sh | 15 ++--
static/js/script.js | 20 +++--
10 files changed, 197 insertions(+), 33 deletions(-)
create mode 100644 docs/validation/runtime-ui-ddd-audit-2026-05-03.md
diff --git a/Makefile b/Makefile
index 400c225..311e250 100644
--- a/Makefile
+++ b/Makefile
@@ -6,9 +6,6 @@ export
endif
PORT ?= 8080
-LLM_RUNTIME ?= local
-LLM_BASE_URL ?= http://$(LLAMACPP_HOST):$(LLAMACPP_PORT)/v1
-LLM_MODEL ?= gemma4:31b
EVO_X2_TAILNET_HOST ?= evo-x2.tailb30e58.ts.net
EVO_X2_SSH_HOST ?= evo-x2
EVO_X2_SSH_LOCAL_PORT ?= 21434
@@ -16,6 +13,9 @@ EVO_X2_OLLAMA_LLM_BASE_URL ?= http://$(EVO_X2_TAILNET_HOST)/v1
EVO_X2_LLAMA_CPP_LLM_BASE_URL ?= http://$(EVO_X2_TAILNET_HOST)/llama/v1
EVO_X2_LLM_BASE_URL ?= $(EVO_X2_OLLAMA_LLM_BASE_URL)
EVO_X2_SSH_LLM_BASE_URL ?= http://127.0.0.1:$(EVO_X2_SSH_LOCAL_PORT)/v1
+LLM_RUNTIME ?= remote
+LLM_BASE_URL ?= $(EVO_X2_LLM_BASE_URL)
+LLM_MODEL ?= gemma4:31b
EVO_X2_LLM_MODEL ?= gemma4:31b
EVO_X2_BRIEF_LLM_MODEL ?= qwen3.6:27b
EVO_X2_STYLE_LLM_MODEL ?= gemma4:e2b
diff --git a/cmd/server/main.go b/cmd/server/main.go
index e69a362..345d7c2 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -28,6 +28,9 @@ func main() {
// 静的ファイルの配信 (staticディレクトリをルートとして提供)
fs := http.FileServer(http.Dir("static"))
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs))
+ r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }).Methods("GET")
// APIエンドポイントの設定
r.HandleFunc("/api/generate", handlers.GenerateArticleHandler).Methods("POST")
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 7cb8666..07993f9 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -211,15 +211,17 @@ Current implementation status as of 2026-05-03:
- Phase A3 is implemented in code: the generated Markdown textarea is editable, preview rendering live-syncs through `marked`, both preview and Markdown tabs can copy content, and `POST /api/drafts/{id}/regenerate-section` rewrites exactly one `## ` subtree while preserving the rest of the draft byte-for-byte ([#19](https://github.com/terisuke/note_maker/issues/19)). Validation is recorded in [Issue 19 section regeneration validation](../validation/issue-19-section-regeneration-2026-05-02.md).
- 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 in the current cut: `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 web app can opt in with `WORKFLOW_STORE_DRIVER=sqlite`; the JSON store remains the default compatibility path ([#26](https://github.com/terisuke/note_maker/issues/26)).
-- Phase D1 is implemented in the current cut: 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.0% statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)).
-- Runtime runner support is implemented in the current cut: `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)).
+- 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 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.
Near-term execution order:
-1. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
-2. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
-3. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
+1. Runtime default verification ([#63](https://github.com/terisuke/note_maker/issues/63)) — confirm the browser app no longer reaches for workstation-local inference first and that favicon noise is removed.
+2. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
+3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
+4. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
## Tracked issues
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index fa22553..47ccd85 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -52,7 +52,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 default compatibility path until the #14 import/export follow-up is explicit.
+- 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 D (Quality): handler tests are mandatory before any further endpoint-heavy UI work lands. Coverage gate: `internal/handlers/workflow.go` ≥ 80 %.
## Architectural Guardrails
@@ -69,7 +69,7 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) (
- Note.com access belongs in `internal/infrastructure/note`.
- OpenAI-compatible local LLM access belongs in `internal/infrastructure/llamacpp`.
- In-memory/file repositories belong in `internal/infrastructure/repository`.
- - Evo X2 Ollama is the primary heavy-inference runtime and must be reached through the Tailnet OpenAI-compatible API (`http://evo-x2.tailb30e58.ts.net/v1` by default) in `make evo-x2` and scenario targets.
+ - Evo X2 Ollama is the primary heavy-inference runtime and must be reached through the Tailnet OpenAI-compatible API (`http://evo-x2.tailb30e58.ts.net/v1` by default) in `make dev`, `make evo-x2`, the plain web server, and scenario targets.
- The fallback order is Evo X2 Ollama → Evo X2 llama.cpp (`http://evo-x2.tailb30e58.ts.net/llama/v1`) → workstation-local llama.cpp.
- SSH tunnels are allowed only as explicit developer diagnostics, not as the product default, because they depend on per-device SSH setup.
- Local llama.cpp (`http://127.0.0.1:8081/v1`) is fallback only. Do not set `LLM_BASE_URL` to local Ollama or local llama.cpp for Evo X2 validation unless the test is explicitly measuring fallback behavior.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index f88c37b..efdb4da 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -19,12 +19,10 @@ Implemented and merged:
- [#24](https://github.com/terisuke/note_maker/issues/24) — built-in `terisuke` and `cloudia` persona seeds.
- [#25](https://github.com/terisuke/note_maker/issues/25) — persona- and format-aware question templates plus the media matrix scenario.
- [#38](https://github.com/terisuke/note_maker/issues/38) — Evo X2 Tailnet OpenAI-compatible API as the primary runtime.
-
-Implemented in the current cut:
-
- [#26](https://github.com/terisuke/note_maker/issues/26) — SQLite-backed workflow store with project/article/session/draft/source snapshot schema and explicit opt-in web-app wiring via `WORKFLOW_STORE_DRIVER=sqlite`.
- [#29](https://github.com/terisuke/note_maker/issues/29) — focused handler tests for the expanded `workflow.go` surface; `go test ./internal/handlers -cover` now reaches 80.0%.
- [#57](https://github.com/terisuke/note_maker/issues/57) — live media-matrix runner and aggregate JSON/Markdown evaluator with offline planned mode by default.
+- [#61](https://github.com/terisuke/note_maker/issues/61) / [PR #62](https://github.com/terisuke/note_maker/pull/62) — workflow storage mode can be inspected and switched from the settings UI; environment-locked deployments remain read-only.
Open and active:
@@ -33,6 +31,8 @@ Open and active:
- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
- 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).
+- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
+- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
## Final evaluation target
@@ -62,7 +62,7 @@ Each live run must record:
The three prerequisites before running the full multi-medium Evo X2 evaluation are now mostly in place:
-1. **Persistence first**: #26 adds SQLite storage for sessions, briefs, source snapshots, drafts, verification, and section-regeneration versions. The next UI work can now persist product memory instead of only loose files.
+1. **Persistence first**: #26 adds SQLite storage for sessions, briefs, source snapshots, drafts, verification, and section-regeneration versions. #61/#62 makes the storage driver visible and switchable from the settings UI, so users do not have to choose it only through make/env setup.
2. **Handler coverage gate**: #29 raises `internal/handlers` coverage to 80.0%, including SSE, edit/fork, template, regenerate-section, and SQLite driver selection paths.
3. **Scenario ownership**: #57 adds the reusable live runner/aggregate evaluator. #40 remains the owner for actual Evo X2 Tailnet quality results.
@@ -80,7 +80,7 @@ Lane A and Lane B can run immediately in parallel. Lane C can start by implement
## Recommended order
-1. Merge the current #26/#29/#57 implementation PR.
+1. Verify the browser app with the #63 runtime defaults: `/api/models` should hit Evo X2 Tailnet first, and `/api/drafts` SSE should report the actual endpoint/model before generation starts.
2. Run one bounded Evo X2 live case through #57 and attach it to #40 to verify the runner with real latency/score data.
3. Start #27 and #28 in parallel so persisted sessions, guides, and draft artifacts become visible in the web app.
4. Start #13 once the history/artifact UI has enough stable browser surface.
diff --git a/docs/validation/runtime-ui-ddd-audit-2026-05-03.md b/docs/validation/runtime-ui-ddd-audit-2026-05-03.md
new file mode 100644
index 0000000..baacbaf
--- /dev/null
+++ b/docs/validation/runtime-ui-ddd-audit-2026-05-03.md
@@ -0,0 +1,79 @@
+# Runtime UI and DDD alignment audit - 2026-05-03
+
+## Scope
+
+This audit reconciles the current implementation with ADR 0002, the open issue set, and the browser failure reported on 2026-05-03.
+
+## Browser failure analysis
+
+Observed while the app was running from the plain server defaults:
+
+- `GET /api/models` returned HTTP 500 because the LLM client attempted `http://127.0.0.1:8081/v1/models` and the connection was refused.
+- `POST /api/drafts` with `Accept: text/event-stream` returned HTTP 200, then emitted `event: error` with `DRAFT_GENERATION_FAILED` because `http://127.0.0.1:8081/v1/chat/completions` was refused.
+- `GET /favicon.ico` returned 404. This was noisy but not the draft-generation failure.
+
+Root cause: the Makefile remote path already used Evo X2 Tailnet, but plain web-app startup and the LLM client default still treated workstation-local llama.cpp as the primary endpoint. That contradicts the intended order recorded in ADR 0002 and the guardrails:
+
+1. Evo X2 Ollama over Tailscale OpenAI-compatible API.
+2. Evo X2 llama.cpp over Tailscale OpenAI-compatible API.
+3. Workstation-local llama.cpp as the last fallback.
+
+## Fix in this cut
+
+Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the runtime order for normal browser use:
+
+- `internal/infrastructure/llamacpp` now defaults to `http://evo-x2.tailb30e58.ts.net/v1`.
+- When that default is used, fallback endpoints are `http://evo-x2.tailb30e58.ts.net/llama/v1` and then `http://127.0.0.1:8081/v1`.
+- Phase defaults now match the current operational plan: style/article `gemma4:e2b`, brief `qwen3.6:27b`, draft `gemma4:31b`, verify `gemma4:latest`.
+- `make dev` and `scripts/dev.sh` default to remote runtime instead of starting workstation-local llama.cpp.
+- The draft UI reports the actual SSE endpoint/model on `runtime_connected`.
+- `/favicon.ico` returns 204 to remove the unrelated browser-console noise.
+
+## Verification in this cut
+
+Commands run from a plain `PORT=18080 go run ./cmd/server` startup:
+
+- `curl -i http://127.0.0.1:18080/favicon.ico` returned `HTTP/1.1 204 No Content`.
+- `curl -i http://127.0.0.1:18080/api/models` returned `HTTP/1.1 200 OK` with Evo X2 model IDs including `gemma4:e2b`, `gemma4:latest`, `gemma4:31b`, `qwen3.6:27b`, and `qwen3.6:35b`.
+
+This proves the browser-visible model endpoint no longer defaults to the failed workstation-local `127.0.0.1:8081` path. A full draft-generation score run remains under #40 because it is a live Evo X2 quality/latency measurement, not a startup-routing smoke test.
+
+## Issue state
+
+Merged and no longer current-cut work:
+
+- #11, #17, #18, #19, #20: Phase A and strict Terisuke style work.
+- #21, #22, #23, #24, #25: Phase B persona, format, source, seed, and question-template work.
+- #26: SQLite workflow store foundation.
+- #29: handler coverage.
+- #57: live media-matrix runner.
+- #61: storage driver settings UI.
+
+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.
+- #36 and #45: fallback quality and llama.cpp swap as P2 runtime work.
+- #15: desktop/app-like packaging after the browser workflow is stable.
+
+## DDD alignment
+
+The implementation is partly aligned with DDD:
+
+- Domain concepts are explicit: `internal/domain/persona`, `internal/domain/format`, `internal/domain/brief`, `internal/domain/author`, and `internal/domain/article`.
+- Application services own core workflow behavior: `internal/application/brief` handles interview progression and fork-on-edit; `internal/application/draft` handles prompt assembly, validation, section regeneration, scoring, and lightweight verification.
+- Infrastructure adapters are separated for LLM access, source fetching, JSON memory, and SQLite persistence.
+- HTTP handlers mostly translate request/response shapes into domain/application calls rather than embedding persona or format rules directly.
+
+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.
+- 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.
+
+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/infrastructure/llamacpp/client.go b/internal/infrastructure/llamacpp/client.go
index 5a23963..cf00cf6 100644
--- a/internal/infrastructure/llamacpp/client.go
+++ b/internal/infrastructure/llamacpp/client.go
@@ -15,8 +15,10 @@ import (
)
const (
- defaultBaseURL = "http://127.0.0.1:8081/v1"
- defaultModel = "gemma4:31b"
+ defaultBaseURL = "http://evo-x2.tailb30e58.ts.net/v1"
+ defaultEvoX2LlamaCPPBaseURL = "http://evo-x2.tailb30e58.ts.net/llama/v1"
+ defaultLocalBaseURL = "http://127.0.0.1:8081/v1"
+ defaultModel = "gemma4:31b"
)
// Client calls an OpenAI-compatible local LLM API such as llama.cpp or Ollama.
@@ -51,8 +53,10 @@ func NewClientFromEnvForPurpose(purpose string) (*Client, error) {
func newClientFromEnvForPurpose(purpose, modelOverride string) (*Client, error) {
baseURL := firstEnv("LLM_BASE_URL", "LLAMACPP_BASE_URL")
+ usingDefaultBaseURL := false
if baseURL == "" {
baseURL = defaultBaseURL
+ usingDefaultBaseURL = true
}
model := strings.TrimSpace(modelOverride)
if model == "" {
@@ -65,7 +69,7 @@ func newClientFromEnvForPurpose(purpose, modelOverride string) (*Client, error)
if err != nil {
return nil, err
}
- fallback, err := fallbackChainFromEnv(purpose, model)
+ fallback, err := fallbackChainFromEnv(purpose, model, usingDefaultBaseURL)
if err != nil {
return nil, err
}
@@ -92,7 +96,21 @@ func modelFromEnv(purpose string) string {
return model
}
}
- return firstEnv("LLM_MODEL", "LLAMACPP_MODEL")
+ if model := firstEnv("LLM_MODEL", "LLAMACPP_MODEL"); model != "" {
+ return model
+ }
+ switch purpose {
+ case "STYLE", "ARTICLE":
+ return "gemma4:e2b"
+ case "BRIEF":
+ return "qwen3.6:27b"
+ case "DRAFT":
+ return "gemma4:31b"
+ case "VERIFY":
+ return "gemma4:latest"
+ default:
+ return ""
+ }
}
func firstEnv(names ...string) string {
@@ -104,7 +122,7 @@ func firstEnv(names ...string) string {
return ""
}
-func fallbackChainFromEnv(purpose, primaryModel string) (*Client, error) {
+func fallbackChainFromEnv(purpose, primaryModel string, usingDefaultBaseURL bool) (*Client, error) {
purpose = strings.ToUpper(strings.TrimSpace(purpose))
keys := func(suffix string) []string {
if purpose == "" {
@@ -134,6 +152,9 @@ func fallbackChainFromEnv(purpose, primaryModel string) (*Client, error) {
baseURLs = []string{baseURL}
}
}
+ if len(baseURLs) == 0 && usingDefaultBaseURL {
+ baseURLs = []string{defaultEvoX2LlamaCPPBaseURL, defaultLocalBaseURL}
+ }
models := splitEnvList(firstEnv(listKeys("MODELS")...))
if len(models) == 0 {
model := firstEnv(keys("LLM_MODEL")...)
diff --git a/internal/infrastructure/llamacpp/client_test.go b/internal/infrastructure/llamacpp/client_test.go
index 0a6b413..4486fcd 100644
--- a/internal/infrastructure/llamacpp/client_test.go
+++ b/internal/infrastructure/llamacpp/client_test.go
@@ -186,6 +186,54 @@ func TestNewClientFromEnvFallsBackToLegacySettings(t *testing.T) {
}
}
+func TestNewClientFromEnvDefaultsToEvoX2TailnetPrimary(t *testing.T) {
+ clearLLMEnv(t)
+
+ client, err := NewClientFromEnvForPurpose("brief")
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+ if client.baseURL != defaultBaseURL {
+ t.Fatalf("unexpected primary base URL: %s", client.baseURL)
+ }
+ if client.model != "qwen3.6:27b" {
+ t.Fatalf("unexpected brief model: %s", client.model)
+ }
+ if client.fallback == nil || client.fallback.baseURL != defaultEvoX2LlamaCPPBaseURL {
+ t.Fatalf("unexpected first fallback: %#v", client.fallback)
+ }
+ if client.fallback.fallback == nil || client.fallback.fallback.baseURL != defaultLocalBaseURL {
+ t.Fatalf("unexpected second fallback: %#v", client.fallback.fallback)
+ }
+}
+
+func clearLLMEnv(t *testing.T) {
+ t.Helper()
+ for _, key := range []string{
+ "LLM_BASE_URL",
+ "LLAMACPP_BASE_URL",
+ "LLM_MODEL",
+ "LLAMACPP_MODEL",
+ "STYLE_LLM_MODEL",
+ "BRIEF_LLM_MODEL",
+ "ARTICLE_LLM_MODEL",
+ "DRAFT_LLM_MODEL",
+ "VERIFY_LLM_MODEL",
+ "LLM_FALLBACK_BASE_URLS",
+ "FALLBACK_LLM_BASE_URLS",
+ "FALLBACK_LLM_BASE_URL",
+ "FALLBACK_LLAMACPP_BASE_URL",
+ "BRIEF_LLM_FALLBACK_BASE_URLS",
+ "BRIEF_FALLBACK_LLM_BASE_URLS",
+ "BRIEF_FALLBACK_LLM_BASE_URL",
+ "BRIEF_LLM_FALLBACK_MODELS",
+ "BRIEF_FALLBACK_LLM_MODELS",
+ "BRIEF_FALLBACK_LLM_MODEL",
+ } {
+ t.Setenv(key, "")
+ }
+}
+
func TestGenerateUsesFallbackClientWhenPrimaryFails(t *testing.T) {
fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 3d71bc6..ccdf882 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -9,17 +9,20 @@ if [ "${NOTE_MAKER_SKIP_ENV:-0}" != "1" ] && [ -f .env ]; then
fi
PORT="${PORT:-8080}"
-LLM_RUNTIME="${LLM_RUNTIME:-local}"
+EVO_X2_TAILNET_HOST="${EVO_X2_TAILNET_HOST:-evo-x2.tailb30e58.ts.net}"
+EVO_X2_OLLAMA_LLM_BASE_URL="${EVO_X2_OLLAMA_LLM_BASE_URL:-http://${EVO_X2_TAILNET_HOST}/v1}"
+EVO_X2_LLAMA_CPP_LLM_BASE_URL="${EVO_X2_LLAMA_CPP_LLM_BASE_URL:-http://${EVO_X2_TAILNET_HOST}/llama/v1}"
+LLM_RUNTIME="${LLM_RUNTIME:-remote}"
LLAMACPP_HOST="${LLAMACPP_HOST:-127.0.0.1}"
LLAMACPP_PORT="${LLAMACPP_PORT:-8081}"
-LLM_BASE_URL="${LLM_BASE_URL:-http://${LLAMACPP_HOST}:${LLAMACPP_PORT}/v1}"
+LLM_BASE_URL="${LLM_BASE_URL:-${EVO_X2_OLLAMA_LLM_BASE_URL}}"
LLM_MODEL="${LLM_MODEL:-${LLAMACPP_MODEL:-gemma4:31b}}"
-STYLE_LLM_MODEL="${STYLE_LLM_MODEL:-${LLM_MODEL}}"
-BRIEF_LLM_MODEL="${BRIEF_LLM_MODEL:-${LLM_MODEL}}"
-ARTICLE_LLM_MODEL="${ARTICLE_LLM_MODEL:-${LLM_MODEL}}"
+STYLE_LLM_MODEL="${STYLE_LLM_MODEL:-gemma4:e2b}"
+BRIEF_LLM_MODEL="${BRIEF_LLM_MODEL:-qwen3.6:27b}"
+ARTICLE_LLM_MODEL="${ARTICLE_LLM_MODEL:-gemma4:e2b}"
DRAFT_LLM_MODEL="${DRAFT_LLM_MODEL:-${LLM_MODEL}}"
VERIFY_LLM_MODEL="${VERIFY_LLM_MODEL:-gemma4:latest}"
-LLM_FALLBACK_BASE_URLS="${LLM_FALLBACK_BASE_URLS:-}"
+LLM_FALLBACK_BASE_URLS="${LLM_FALLBACK_BASE_URLS:-${EVO_X2_LLAMA_CPP_LLM_BASE_URL},http://${LLAMACPP_HOST}:${LLAMACPP_PORT}/v1}"
STYLE_LLM_FALLBACK_MODELS="${STYLE_LLM_FALLBACK_MODELS:-}"
BRIEF_LLM_FALLBACK_MODELS="${BRIEF_LLM_FALLBACK_MODELS:-}"
ARTICLE_LLM_FALLBACK_MODELS="${ARTICLE_LLM_FALLBACK_MODELS:-}"
diff --git a/static/js/script.js b/static/js/script.js
index 8d24d85..8412728 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -369,7 +369,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
state.draftAbortController = new AbortController();
setDraftStreaming(true);
- el.draftStatus.textContent = 'Evo X2 の OpenAI互換APIで下書きを生成しています。';
+ el.draftStatus.textContent = 'OpenAI互換APIで下書きを生成しています。';
let draftBuffer = '';
el.markdownOutput.value = '';
el.previewContent.innerHTML = '';
@@ -393,11 +393,11 @@ document.addEventListener('DOMContentLoaded', () => {
signal: state.draftAbortController.signal,
onEvent(event, data) {
if (event === 'status') {
- el.draftStatus.textContent = draftStatusText(data.status, data.elapsed_ms);
+ el.draftStatus.textContent = draftStatusText(data);
return;
}
if (event === 'heartbeat') {
- el.draftStatus.textContent = draftStatusText('running', data.elapsed_ms);
+ el.draftStatus.textContent = draftStatusText({ ...data, status: 'running' });
return;
}
if (event === 'chunk') {
@@ -1333,8 +1333,11 @@ document.addEventListener('DOMContentLoaded', () => {
onEvent(event, data);
}
- function draftStatusText(status, elapsedMS = 0) {
- const seconds = Math.max(0, Math.round(Number(elapsedMS || 0) / 1000));
+ function draftStatusText(statusOrData, elapsedMS = 0) {
+ const data = typeof statusOrData === 'object' && statusOrData !== null
+ ? statusOrData
+ : { status: statusOrData, elapsed_ms: elapsedMS };
+ const status = data.status;
const labels = {
stream_opened: '接続しました',
draft_generation_started: '本文を生成しています',
@@ -1345,7 +1348,12 @@ document.addEventListener('DOMContentLoaded', () => {
running: '生成を継続しています',
completed: '生成が完了しました',
};
- return `${labels[status] || status} (${seconds}s)`;
+ const elapsed = Math.max(0, Math.round(Number(data.elapsed_ms || elapsedMS || 0) / 1000));
+ if (status === 'runtime_connected' && data.endpoint) {
+ const model = data.model ? ` / ${data.model}` : '';
+ return `${labels[status]}: ${data.endpoint}${model} (${elapsed}s)`;
+ }
+ return `${labels[status] || status} (${elapsed}s)`;
}
function draftDoneText(data) {
From 07f3ffabdc1ae9f2dbdc247ff0d13d8ff31ecb9a Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 01:35:56 +0900
Subject: [PATCH 21/33] Simplify brief interview questions (#67)
---
cmd/scenario/media_matrix/main.go | 20 +++
...02-multi-persona-multi-format-extension.md | 3 +-
.../issue-adr-guardrails.md | 4 +-
.../next-implementation-cut.md | 3 +-
...sue-66-plain-brief-questions-2026-05-03.md | 42 +++++++
internal/application/brief/service_test.go | 6 +
internal/application/draft/prompt.go | 43 ++++++-
internal/application/draft/service_test.go | 7 ++
internal/domain/brief/session.go | 25 ++--
internal/domain/brief/session_test.go | 47 ++++---
internal/domain/brief/types.go | 116 +++++++++++++++---
internal/handlers/workflow.go | 6 +-
internal/handlers/workflow_handler_test.go | 7 +-
static/css/style.css | 7 +-
static/js/script.js | 48 ++++++--
15 files changed, 322 insertions(+), 62 deletions(-)
create mode 100644 docs/validation/issue-66-plain-brief-questions-2026-05-03.md
diff --git a/cmd/scenario/media_matrix/main.go b/cmd/scenario/media_matrix/main.go
index 22ce6d8..f48f7ae 100644
--- a/cmd/scenario/media_matrix/main.go
+++ b/cmd/scenario/media_matrix/main.go
@@ -398,10 +398,18 @@ func answerForQuestion(item matrixCase, questionID string) string {
return item.OpeningEpisode
case briefdomain.QuestionIDReader:
return item.Reader
+ case briefdomain.QuestionIDReaderProblem:
+ return "媒体ごとの作法が違い、どの粒度で書けば読者に届くか迷っている。"
case briefdomain.QuestionIDExpectedReaderAction:
return item.ExpectedReaderAction
+ case briefdomain.QuestionIDKeyTakeaway:
+ return "媒体に合わせて、同じ知見でも入口と根拠の出し方を変える。"
case briefdomain.QuestionIDMustInclude:
return item.MustInclude
+ case briefdomain.QuestionIDConcreteExample:
+ return "実際の取得元、Markdown形式、検証コマンド、生成後の評価結果を例として出す。"
+ case briefdomain.QuestionIDEvidence:
+ return "シナリオCLIの出力、style score、verification PASS/NEEDS_REVIEW、生成文字数を根拠にする。"
case briefdomain.QuestionIDPersonalContext:
return item.PersonalContext
case briefdomain.QuestionIDExclusions:
@@ -410,12 +418,24 @@ func answerForQuestion(item matrixCase, questionID string) string {
return item.TargetLengthStructure
case briefdomain.QuestionIDToneStance:
return item.ToneStance
+ case briefdomain.QuestionIDTitleKeywords:
+ return "AI駆動開発、媒体別、下書き、検証、Evo X2。"
case briefdomain.QuestionIDStoryArc:
return "導入の違和感から、実践で見えた発見へ進み、読者が次に試す一歩で締める。"
case briefdomain.QuestionIDTargetStack:
return "Go 1.26、OpenAI互換API、Ollama/Evo X2、Markdown validator、ローカルシナリオCLI。"
+ case briefdomain.QuestionIDPrerequisiteKnowledge:
+ return "GoとMarkdownの基礎は知っているが、媒体別の記法差分やローカルLLM運用はこれから試す読者。"
case briefdomain.QuestionIDTechnicalProof:
return "実行コマンド、JSON出力、本文長、style score、verification結果を比較表に残す。"
+ case briefdomain.QuestionIDCodeExamples:
+ return "必要ならMakefileターゲット、curl、JSONの抜粋を短く載せる。"
+ case briefdomain.QuestionIDReferences:
+ return "Zenn/Qiita公式Markdownガイド、corsweb2024のMarkdown記事、過去の検証ログ。"
+ case briefdomain.QuestionIDCorBlogPurpose:
+ return "技術知見の報告を主にし、社員や採用候補へ開発方針も伝える。"
+ case briefdomain.QuestionIDCorBlogNextAction:
+ return "Cor.incの開発文化と検証姿勢を理解し、相談や協業につなげてもらう。"
case briefdomain.QuestionIDHomepageCTA:
return "問い合わせまたは技術相談への導線を置き、検証可能な発信基盤を短く伝える。"
case briefdomain.QuestionIDHomepageTrust:
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 07993f9..6e7fb82 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -215,10 +215,11 @@ Current implementation status as of 2026-05-03:
- 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.
+- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
Near-term execution order:
-1. Runtime default verification ([#63](https://github.com/terisuke/note_maker/issues/63)) — confirm the browser app no longer reaches for workstation-local inference first and that favicon noise is removed.
+1. Browser sanity check for the #66 interview flow — confirm the new smaller questions are easier to answer before spending Evo X2 runtime.
2. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
4. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index 47ccd85..fe5b069 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -93,6 +93,8 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) (
The interview is a structured取材 session, not a generic chat.
- Fixed questions run first in deterministic order.
+- Fixed questions should be small and plain enough to answer in one or two short sentences. If one question asks for multiple kinds of thinking, split it into smaller template questions.
+- Optional questions must be clearly optional in the UI and may advance as `未定`; do not force the user to invent detail just to continue.
- Deep-dive questions run after fixed questions.
- A deep-dive question must store:
- `target_question_id`
@@ -101,7 +103,7 @@ The interview is a structured取材 session, not a generic chat.
- Follow-ups must ask exactly one question.
- Follow-ups must not be yes/no questions.
- Follow-ups must not be binary choice questions.
-- Follow-ups must ask for concrete scene, reason, turning point, emotion, or reader lesson.
+- Follow-ups must ask for one concrete scene, step, number, reason, turning point, emotion, or reader lesson.
- If LLM-generated follow-up text fails validation, use a rule-based fallback.
## Draft Guardrails
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index efdb4da..34d2578 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -33,6 +33,7 @@ Open and active:
- 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).
- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
+- 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).
## Final evaluation target
@@ -80,7 +81,7 @@ Lane A and Lane B can run immediately in parallel. Lane C can start by implement
## Recommended order
-1. Verify the browser app with the #63 runtime defaults: `/api/models` should hit Evo X2 Tailnet first, and `/api/drafts` SSE should report the actual endpoint/model before generation starts.
+1. Browser-check the #66 smaller question flow with at least note and one technical format. Do this before spending Evo X2 runtime.
2. Run one bounded Evo X2 live case through #57 and attach it to #40 to verify the runner with real latency/score data.
3. Start #27 and #28 in parallel so persisted sessions, guides, and draft artifacts become visible in the web app.
4. Start #13 once the history/artifact UI has enough stable browser surface.
diff --git a/docs/validation/issue-66-plain-brief-questions-2026-05-03.md b/docs/validation/issue-66-plain-brief-questions-2026-05-03.md
new file mode 100644
index 0000000..3af2b14
--- /dev/null
+++ b/docs/validation/issue-66-plain-brief-questions-2026-05-03.md
@@ -0,0 +1,42 @@
+# Issue 66 plain brief questions validation - 2026-05-03
+
+## Goal
+
+Before live Evo X2 measurement, reduce the user's answering burden in the brief interview. The previous fixed questions were too broad and editorial, which made the one-question-at-a-time flow feel mentally heavy.
+
+## Implementation
+
+- Rewrote the base fixed questions in plain Japanese.
+- Split broad prompts into smaller prompts:
+ - reader problem,
+ - key takeaway,
+ - concrete example,
+ - evidence,
+ - title or heading keywords.
+- Expanded medium-specific prompts:
+ - note: emotional/story arc,
+ - Zenn/Qiita: stack, prerequisite knowledge, reproduction proof, code examples, references,
+ - Cor company blog: technical report vs vision-sharing purpose and desired reader reaction.
+- Softened fallback follow-up wording so it asks for one concrete scene, step, number, feeling, or reason.
+- Updated the LLM follow-up prompt to request shorter, easier questions and avoid difficult editorial terms.
+- The UI now wraps template question text, labels required/optional questions, and lets optional questions advance as `未定` when the answer box is empty.
+- Additional question answers now keep their question label in the final draft prompt instead of being appended as unlabeled fragments.
+
+## Verification
+
+Commands:
+
+```bash
+go test ./internal/domain/brief ./internal/application/brief ./internal/application/draft ./internal/handlers ./cmd/scenario/media_matrix
+node --check static/js/script.js
+go run ./cmd/scenario/media_matrix
+```
+
+Result:
+
+- All checks passed.
+- Offline media matrix completed with `question_template_ids=14`, `composed_templates=10`, and `cases=6`.
+
+## Remaining measurement
+
+This change intentionally does not run the live Evo X2 media matrix. The next step is to run one bounded #40 case after confirming the new interview flow is usable in the browser, then proceed to the full Note/Qiita/Zenn/Cor blog comparison.
diff --git a/internal/application/brief/service_test.go b/internal/application/brief/service_test.go
index 9227b7a..9b57b2b 100644
--- a/internal/application/brief/service_test.go
+++ b/internal/application/brief/service_test.go
@@ -207,12 +207,18 @@ func fixedAnswers() []string {
"Local article generation with a small deterministic workflow.",
"Open with a failed local LLM run that timed out while drafting.",
"Solo developers who write note.com articles with local tools.",
+ "They are unsure how to turn rough technical notes into a readable article.",
"They should try a three-phase workflow before drafting.",
+ "The smallest useful workflow is style analysis, interview, draft, and verification.",
"Mention style analysis, brief interviews, and final draft checks.",
+ "Use a failed timeout and a fixed follow-up question as concrete examples.",
+ "Compare elapsed seconds, style score, verification result, and generated length.",
"Use the author's personal history as a musician and engineer.",
"Avoid cloud-only assumptions.",
"3000字前後 with six sections.",
"Practical and introspective.",
+ "local LLM, article draft, verification.",
+ "Start with friction, move to workflow design, and close with one small next step.",
}
}
diff --git a/internal/application/draft/prompt.go b/internal/application/draft/prompt.go
index 7f48a84..1345d40 100644
--- a/internal/application/draft/prompt.go
+++ b/internal/application/draft/prompt.go
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
+ 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"
)
@@ -145,6 +146,7 @@ func BuildSectionRegenerationPrompt(guide WritingStyleGuide, brief ArticleBrief,
appendLine(&prompt, "著者本人の属人的な文脈", brief.PersonalContext)
appendLine(&prompt, "含めないこと", brief.Exclusions)
appendLine(&prompt, "トーンと立場", brief.ToneStance)
+ appendCustomAnswers(&prompt, brief.CustomAnswers)
appendDeepDives(&prompt, brief.DeepDives)
if calibration := strictMetricCalibration(profile, brief, guide); calibration != "" {
prompt.WriteString("\n## strict style calibration\n")
@@ -415,12 +417,51 @@ func appendCustomAnswers(builder *strings.Builder, values []BriefAnswer) {
for _, value := range values {
content := strings.TrimSpace(value.Content)
if content != "" {
- lines = append(lines, content)
+ lines = append(lines, briefQuestionLabel(value.QuestionID)+": "+content)
}
}
appendList(builder, "追加質問メモ", lines)
}
+func briefQuestionLabel(questionID string) string {
+ switch questionID {
+ case briefdomain.QuestionIDReaderProblem:
+ return "読者の困りごと"
+ case briefdomain.QuestionIDKeyTakeaway:
+ return "持ち帰ってほしいこと"
+ case briefdomain.QuestionIDConcreteExample:
+ return "具体例・失敗例"
+ case briefdomain.QuestionIDEvidence:
+ return "根拠・数字・比較"
+ case briefdomain.QuestionIDTitleKeywords:
+ return "タイトル候補・見出し語"
+ case briefdomain.QuestionIDStoryArc:
+ return "note向けの感情の流れ"
+ case briefdomain.QuestionIDTargetStack:
+ return "技術・ツール・バージョン"
+ case briefdomain.QuestionIDPrerequisiteKnowledge:
+ return "読者の前提知識"
+ case briefdomain.QuestionIDTechnicalProof:
+ return "再現手順・検証結果"
+ case briefdomain.QuestionIDCodeExamples:
+ return "コード例・コマンド"
+ case briefdomain.QuestionIDReferences:
+ return "参考リンク"
+ case briefdomain.QuestionIDCorBlogPurpose:
+ return "自社ブログでの目的"
+ case briefdomain.QuestionIDCorBlogNextAction:
+ return "会社ブログとしての読後感"
+ case briefdomain.QuestionIDHomepageCTA:
+ return "CTA"
+ case briefdomain.QuestionIDHomepageTrust:
+ return "信頼の根拠"
+ case briefdomain.QuestionIDCloudiaViewpoint:
+ return "クラウディア視点"
+ default:
+ return questionID
+ }
+}
+
func truncateRunes(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
diff --git a/internal/application/draft/service_test.go b/internal/application/draft/service_test.go
index 2ad9a64..083f971 100644
--- a/internal/application/draft/service_test.go
+++ b/internal/application/draft/service_test.go
@@ -8,6 +8,7 @@ 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"
)
@@ -27,6 +28,10 @@ func TestGenerateBuildsPromptFromGuideAndBriefOnly(t *testing.T) {
PersonalContext: "音楽家からエンジニアになった経験を入れる。",
Exclusions: "Note記事本文の再取得",
TargetLengthStructure: "1200字、導入・本論・結論",
+ CustomAnswers: []BriefAnswer{
+ {QuestionID: briefdomain.QuestionIDReaderProblem, Content: "媒体ごとの書き分けが難しい"},
+ {QuestionID: briefdomain.QuestionIDTitleKeywords, Content: "下書き、Evo X2、検証"},
+ },
},
AuthorProfile: profile,
}
@@ -51,6 +56,8 @@ func TestGenerateBuildsPromptFromGuideAndBriefOnly(t *testing.T) {
"参考記事本文は与えられていません",
"strict style calibration",
"一人称密度",
+ "読者の困りごと: 媒体ごとの書き分けが難しい",
+ "タイトル候補・見出し語: 下書き、Evo X2、検証",
} {
if !strings.Contains(generator.prompt, want) {
t.Fatalf("prompt does not contain %q:\n%s", want, generator.prompt)
diff --git a/internal/domain/brief/session.go b/internal/domain/brief/session.go
index 25bb083..87e78e0 100644
--- a/internal/domain/brief/session.go
+++ b/internal/domain/brief/session.go
@@ -154,6 +154,9 @@ func (s ArticleBriefSession) CustomAnswers() []BriefAnswer {
}
answers := make([]BriefAnswer, 0)
for _, answer := range s.Answers {
+ if strings.TrimSpace(answer.Content) == "" {
+ continue
+ }
if answer.FlowType == QuestionFlowMain && !fixed[answer.QuestionID] {
answers = append(answers, answer)
}
@@ -241,36 +244,36 @@ func FallbackFollowUpText(target ArticleQuestion, answer BriefAnswer, followUpIn
switch target.ID {
case QuestionIDOpeningEpisode:
if followUpIndex == 1 {
- question = "読者に最初に見せたい具体的な場面を、どの描写から始めますか?"
+ question = "その場面で、読者に最初に見せたいものを1つだけ挙げると何ですか?"
break
}
- question = "その時点の感情を、どんな言葉で記事に残しますか?"
+ question = "その時の気持ちを短く書くなら、どんな言葉になりますか?"
case QuestionIDMustInclude:
if followUpIndex == 1 {
- question = "必ず含めたい論点のうち、どの部分に具体的な根拠を足しますか?"
+ question = "必ず入れたいことの中で、特に詳しく説明したいものはどれですか?"
break
}
- question = "その論点から読者に持ち帰ってほしい学びを、どう表現しますか?"
+ question = "その話を信じてもらうために、足せそうな根拠は何ですか?"
case QuestionIDPersonalContext:
if followUpIndex == 1 {
- question = "記事の主張に最も直接つなげたい個人的な経験は何ですか?"
+ question = "あなた自身の経験として、記事に入れると伝わりやすい出来事は何ですか?"
break
}
- question = "記事の中で見せたい個人的な価値観や迷いは何ですか?"
+ question = "その経験から、今の考え方が変わった点はありますか?"
case QuestionIDExpectedReaderAction:
if followUpIndex == 1 {
- question = "読者がその行動を取りたくなる理由を、どの実感から説明しますか?"
+ question = "読者が最初に試せる小さな一歩は何ですか?"
break
}
- question = "読後に読者が想像できる最初の一歩は何ですか?"
+ question = "その一歩を試すと、読者にどんな良いことがありますか?"
case QuestionIDToneStance:
if followUpIndex == 1 {
- question = "記事で最も丁寧に説明したい立場は何ですか?"
+ question = "この文章で一番大事にしたい温度感は何ですか?"
break
}
- question = "そのトーンや立場を支える経験は何ですか?"
+ question = "その温度感にしたい理由は何ですか?"
default:
- question = "記事を実用的にするために、どんな具体的な情報を足しますか?"
+ question = "記事に足すと読みやすくなる具体的な情報を1つ挙げるなら何ですか?"
}
return contextualFollowUpQuestion(answer.Content, question)
}
diff --git a/internal/domain/brief/session_test.go b/internal/domain/brief/session_test.go
index d08ca5d..644c178 100644
--- a/internal/domain/brief/session_test.go
+++ b/internal/domain/brief/session_test.go
@@ -1,7 +1,6 @@
package brief
import (
- "reflect"
"strings"
"testing"
@@ -15,23 +14,33 @@ func TestFixedQuestionsAreDeterministic(t *testing.T) {
QuestionIDTheme,
QuestionIDOpeningEpisode,
QuestionIDReader,
+ QuestionIDReaderProblem,
QuestionIDExpectedReaderAction,
+ QuestionIDKeyTakeaway,
QuestionIDMustInclude,
+ QuestionIDConcreteExample,
+ QuestionIDEvidence,
QuestionIDPersonalContext,
QuestionIDExclusions,
QuestionIDTargetLengthStructure,
QuestionIDToneStance,
+ QuestionIDTitleKeywords,
}
wantText := []string{
- "記事の中心テーマは何ですか?",
- "記事の導入に置く具体的な体験や場面は何ですか?",
- "この記事を届けたい読者は誰ですか?",
- "読後に読者へどんな変化や行動を起こしてほしいですか?",
- "記事に必ず含める論点、事実、手順は何ですか?",
- "著者本人の経験、肩書き、失敗、価値観など、記事に入れるべき属人的な文脈は何ですか?",
- "記事に含めないこと、避けたい表現、断言しないことは何ですか?",
- "目標文字数と記事構成を指定してください。例: 3000字前後、導入・背景・実装・検証・提案・結論。",
- "記事のトーンや立場はどうしますか?内省、技術解説、実用、物語性の比重も指定してください。",
+ "この記事で一番伝えたいことを、ひとことで書くと何ですか?",
+ "冒頭で使えそうな出来事や場面はありますか?いつ・どこで・何が起きましたか?",
+ "誰に向けて書きますか?例: これから試す人、社内メンバー、同じ悩みのある人。",
+ "その読者は今、何に困っている・迷っていると思いますか?",
+ "読み終わった後、その人にまず何をしてほしいですか?",
+ "読者に一番持ち帰ってほしい言葉や考えは何ですか?",
+ "絶対に入れたい事実・手順・名前・数字を箇条書きで教えてください。",
+ "その話を伝えるための具体例、失敗例、画面、コード、会話などはありますか?",
+ "根拠として出せる結果・数字・比較・リンク・観察はありますか?なければ「なし」でOKです。",
+ "あなた自身はなぜこの話を書きたいですか?経験・問題意識・違和感を短く教えてください。",
+ "書かないこと、避けたい言い方、まだ断言しないことはありますか?なければ「なし」でOKです。",
+ "長さと構成の希望はありますか?例: 1500字で軽く、3000字で詳しく、導入→手順→結果。",
+ "文章の雰囲気はどうしますか?例: やさしく、熱量高め、冷静な技術報告、社内向け。",
+ "タイトルや見出しに入れたい言葉はありますか?なければ「未定」でOKです。",
}
if len(questions) != len(wantIDs) {
t.Fatalf("question count = %d, want %d", len(questions), len(wantIDs))
@@ -66,20 +75,21 @@ func TestComposeFixedQuestionsCoversPersonasAndFormats(t *testing.T) {
t.Fatalf("question count = %d, want at least %d", len(questions), len(FixedQuestions()))
}
assertUniqueQuestionIDs(t, questions)
- if personaID == persona.IDTerisuke && formatID == outputformat.IDNoteArticle {
- if !reflect.DeepEqual(questions, FixedQuestions()) {
- t.Fatalf("terisuke note_article template changed:\ngot %#v\nwant %#v", questions, FixedQuestions())
- }
- return
- }
switch formatID {
case outputformat.IDNoteArticle:
assertQuestionPresent(t, questions, QuestionIDStoryArc)
case outputformat.IDMarkdownBlog, outputformat.IDZennArticle, outputformat.IDQiitaArticle:
assertQuestionPresent(t, questions, QuestionIDTargetStack)
+ assertQuestionPresent(t, questions, QuestionIDPrerequisiteKnowledge)
+ assertQuestionPresent(t, questions, QuestionIDCodeExamples)
+ assertQuestionPresent(t, questions, QuestionIDReferences)
case outputformat.IDHomepageSection:
assertQuestionPresent(t, questions, QuestionIDHomepageCTA)
}
+ if formatID == outputformat.IDMarkdownBlog {
+ assertQuestionPresent(t, questions, QuestionIDCorBlogPurpose)
+ assertQuestionPresent(t, questions, QuestionIDCorBlogNextAction)
+ }
if personaID == persona.IDCloudia {
assertQuestionPresent(t, questions, QuestionIDCloudiaViewpoint)
}
@@ -323,12 +333,17 @@ func answeredFixedSession(t *testing.T, overrides map[string]string) ArticleBrie
QuestionIDTheme: "Local article generation with a small deterministic workflow.",
QuestionIDOpeningEpisode: "Open with a failed local LLM run that timed out while drafting.",
QuestionIDReader: "Solo developers who write note.com articles with local tools.",
+ QuestionIDReaderProblem: "They are unsure how to structure practical notes.",
QuestionIDExpectedReaderAction: "They should try a three-phase workflow before drafting.",
+ QuestionIDKeyTakeaway: "Small interviews make drafts easier to evaluate.",
QuestionIDMustInclude: "Mention style analysis, brief interviews, and final draft checks.",
+ QuestionIDConcreteExample: "Use a timeout failure and a repaired draft flow as the example.",
+ QuestionIDEvidence: "Use elapsed seconds, score, and verification status.",
QuestionIDPersonalContext: "Use the author's background as a musician, engineer, and public speaker.",
QuestionIDExclusions: "Avoid cloud-only assumptions.",
QuestionIDTargetLengthStructure: "3000字前後 with six sections.",
QuestionIDToneStance: "Practical and introspective.",
+ QuestionIDTitleKeywords: "local LLM, draft workflow, verification.",
}
for key, value := range overrides {
answers[key] = value
diff --git a/internal/domain/brief/types.go b/internal/domain/brief/types.go
index e357d20..b9bcf33 100644
--- a/internal/domain/brief/types.go
+++ b/internal/domain/brief/types.go
@@ -12,15 +12,25 @@ const (
QuestionIDTheme = "theme"
QuestionIDOpeningEpisode = "opening_episode"
QuestionIDReader = "reader"
+ QuestionIDReaderProblem = "reader_problem"
QuestionIDExpectedReaderAction = "expected_reader_action"
+ QuestionIDKeyTakeaway = "key_takeaway"
QuestionIDMustInclude = "must_include"
+ QuestionIDConcreteExample = "concrete_example"
+ QuestionIDEvidence = "evidence"
QuestionIDPersonalContext = "personal_context"
QuestionIDExclusions = "exclusions"
QuestionIDTargetLengthStructure = "target_length_structure"
QuestionIDToneStance = "tone_stance"
+ QuestionIDTitleKeywords = "title_keywords"
QuestionIDStoryArc = "story_arc"
QuestionIDTargetStack = "target_stack"
+ QuestionIDPrerequisiteKnowledge = "prerequisite_knowledge"
QuestionIDTechnicalProof = "technical_proof"
+ QuestionIDCodeExamples = "code_examples"
+ QuestionIDReferences = "references"
+ QuestionIDCorBlogPurpose = "cor_blog_purpose"
+ QuestionIDCorBlogNextAction = "cor_blog_next_action"
QuestionIDHomepageCTA = "homepage_cta"
QuestionIDHomepageTrust = "homepage_trust"
QuestionIDCloudiaViewpoint = "cloudia_viewpoint"
@@ -165,67 +175,102 @@ func FixedQuestions() []ArticleQuestion {
return []ArticleQuestion{
{
ID: QuestionIDTheme,
- Text: "記事の中心テーマは何ですか?",
+ Text: "この記事で一番伝えたいことを、ひとことで書くと何ですか?",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "theme",
},
{
ID: QuestionIDOpeningEpisode,
- Text: "記事の導入に置く具体的な体験や場面は何ですか?",
+ Text: "冒頭で使えそうな出来事や場面はありますか?いつ・どこで・何が起きましたか?",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "opening_episode",
},
{
ID: QuestionIDReader,
- Text: "この記事を届けたい読者は誰ですか?",
+ Text: "誰に向けて書きますか?例: これから試す人、社内メンバー、同じ悩みのある人。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "reader",
},
+ {
+ ID: QuestionIDReaderProblem,
+ Text: "その読者は今、何に困っている・迷っていると思いますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
{
ID: QuestionIDExpectedReaderAction,
- Text: "読後に読者へどんな変化や行動を起こしてほしいですか?",
+ Text: "読み終わった後、その人にまず何をしてほしいですか?",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "expected_reader_action",
},
+ {
+ ID: QuestionIDKeyTakeaway,
+ Text: "読者に一番持ち帰ってほしい言葉や考えは何ですか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
{
ID: QuestionIDMustInclude,
- Text: "記事に必ず含める論点、事実、手順は何ですか?",
+ Text: "絶対に入れたい事実・手順・名前・数字を箇条書きで教えてください。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "must_include",
},
+ {
+ ID: QuestionIDConcreteExample,
+ Text: "その話を伝えるための具体例、失敗例、画面、コード、会話などはありますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDEvidence,
+ Text: "根拠として出せる結果・数字・比較・リンク・観察はありますか?なければ「なし」でOKです。",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
{
ID: QuestionIDPersonalContext,
- Text: "著者本人の経験、肩書き、失敗、価値観など、記事に入れるべき属人的な文脈は何ですか?",
+ Text: "あなた自身はなぜこの話を書きたいですか?経験・問題意識・違和感を短く教えてください。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "personal_context",
},
{
ID: QuestionIDExclusions,
- Text: "記事に含めないこと、避けたい表現、断言しないことは何ですか?",
+ Text: "書かないこと、避けたい言い方、まだ断言しないことはありますか?なければ「なし」でOKです。",
FlowType: QuestionFlowMain,
Required: false,
TargetField: "exclusions",
},
{
ID: QuestionIDTargetLengthStructure,
- Text: "目標文字数と記事構成を指定してください。例: 3000字前後、導入・背景・実装・検証・提案・結論。",
+ Text: "長さと構成の希望はありますか?例: 1500字で軽く、3000字で詳しく、導入→手順→結果。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "target_length_structure",
},
{
ID: QuestionIDToneStance,
- Text: "記事のトーンや立場はどうしますか?内省、技術解説、実用、物語性の比重も指定してください。",
+ Text: "文章の雰囲気はどうしますか?例: やさしく、熱量高め、冷静な技術報告、社内向け。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "tone_stance",
},
+ {
+ ID: QuestionIDTitleKeywords,
+ Text: "タイトルや見出しに入れたい言葉はありますか?なければ「未定」でOKです。",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
}
}
@@ -238,9 +283,6 @@ func ComposeFixedQuestions(personaID, outputFormatID string) []ArticleQuestion {
}
}
outputFormatID = outputformat.NormalizeID(outputFormatID)
- if personaID == persona.IDTerisuke && outputFormatID == outputformat.IDNoteArticle {
- return FixedQuestions()
- }
questions := FixedQuestions()
questions = append(questions, formatExtensionQuestions(outputFormatID)...)
@@ -252,7 +294,9 @@ func formatExtensionQuestions(outputFormatID string) []ArticleQuestion {
switch outputFormatID {
case outputformat.IDNoteArticle:
return narrativeExtensionQuestions()
- case outputformat.IDMarkdownBlog, outputformat.IDZennArticle, outputformat.IDQiitaArticle:
+ case outputformat.IDMarkdownBlog:
+ return append(technicalExtensionQuestions(), companyBlogExtensionQuestions()...)
+ case outputformat.IDZennArticle, outputformat.IDQiitaArticle:
return technicalExtensionQuestions()
case outputformat.IDHomepageSection:
return homepageExtensionQuestions()
@@ -265,7 +309,7 @@ func narrativeExtensionQuestions() []ArticleQuestion {
return []ArticleQuestion{
{
ID: QuestionIDStoryArc,
- Text: "読み物として印象に残すため、どんな感情の流れやオチを置きますか?",
+ Text: "noteらしい読み物にするなら、どんな順番で気持ちや発見を見せますか?例: 違和感→試したこと→気づき→読者への一言。",
FlowType: QuestionFlowMain,
Required: false,
TargetField: "custom",
@@ -277,14 +321,54 @@ func technicalExtensionQuestions() []ArticleQuestion {
return []ArticleQuestion{
{
ID: QuestionIDTargetStack,
- Text: "対象にする技術スタック、言語、ライブラリ、実行環境、前提バージョンは何ですか?",
+ Text: "扱う技術・ツール・言語・バージョンは何ですか?わかる範囲でOKです。",
FlowType: QuestionFlowMain,
Required: true,
TargetField: "custom",
},
+ {
+ ID: QuestionIDPrerequisiteKnowledge,
+ Text: "読者はどこまで知っている前提にしますか?初心者向けか、経験者向けかも教えてください。",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
{
ID: QuestionIDTechnicalProof,
- Text: "記事内で示す再現手順、コード例、検証結果、失敗例は何ですか?",
+ Text: "再現手順や検証結果として、どこまで記事に載せますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDCodeExamples,
+ Text: "コード例・コマンド・設定ファイルを載せますか?載せるならどの部分ですか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDReferences,
+ Text: "参考リンク、公式ドキュメント、過去記事など、記事からリンクしたいものはありますか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ }
+}
+
+func companyBlogExtensionQuestions() []ArticleQuestion {
+ return []ArticleQuestion{
+ {
+ ID: QuestionIDCorBlogPurpose,
+ Text: "自社ブログとして、今回は技術知見の報告ですか?社員や採用候補へのビジョン共有ですか?",
+ FlowType: QuestionFlowMain,
+ Required: false,
+ TargetField: "custom",
+ },
+ {
+ ID: QuestionIDCorBlogNextAction,
+ Text: "会社ブログとして、読者に最後に何を感じてほしい・相談してほしいですか?",
FlowType: QuestionFlowMain,
Required: false,
TargetField: "custom",
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index 1f00312..73e6f38 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -1038,13 +1038,15 @@ func buildFollowUpPrompt(session briefdomain.ArticleBriefSession, target briefdo
styleGuideMarkdown = "文体ガイド未設定。セッションの既存回答に合わせ、自然な日本語で質問する。"
}
return fmt.Sprintf(`あなたは記事の取材編集者です。
-次の記事ブリーフ回答を深掘りするため、著者に聞く追加質問を1つだけ作ってください。
+次の記事ブリーフ回答を深掘りするため、著者がすぐ答えられる追加質問を1つだけ作ってください。
条件:
- 日本語で質問する
- はい/いいえで答えられる質問にしない
- 選択式にしない
-- 具体的な経験、感情、判断、失敗、価値観のどれかを引き出す
+- 抽象論ではなく、1つの具体的な場面、手順、数字、失敗、気持ち、判断理由のどれかを引き出す
+- 質問は30〜70字程度にする
+- 難しい編集用語を使わない
- 文体ガイドのトーンから外れない
- 質問文だけを出力する
- 質問は必ず「%s」というご回答を踏まえて、から始める
diff --git a/internal/handlers/workflow_handler_test.go b/internal/handlers/workflow_handler_test.go
index b02d76f..28e1fd9 100644
--- a/internal/handlers/workflow_handler_test.go
+++ b/internal/handlers/workflow_handler_test.go
@@ -678,12 +678,17 @@ func sessionWithFixedAnswers(t *testing.T, id, styleProfileID string) briefdomai
"Local workflow tests",
"A handler test failed before reaching a networked LLM.",
"Maintainers adding coverage.",
+ "They need stable endpoint behavior.",
"Keep endpoint behavior stable.",
+ "Small handler tests can prevent runtime regressions.",
"HTTP status codes and persistence checks.",
- "Testing the workflow handler without external services.",
+ "Use the failed handler test as the concrete example.",
+ "Status codes, persisted sessions, and stream events.",
+ "We want local tests to catch workflow drift before live Evo X2 runs.",
"Avoid broad source changes.",
"1200字, validation focused.",
"Practical and concise.",
+ "workflow handler, SSE, persistence.",
}
for _, answer := range answers {
if _, err := session.RecordAnswer(answer); err != nil {
diff --git a/static/css/style.css b/static/css/style.css
index 6953626..2220eb5 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -207,9 +207,14 @@ body {
min-width: 0;
}
-.question-config-row.template input {
+.question-template-text {
background: #f8fafc;
color: var(--muted);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 10px 12px;
+ line-height: 1.55;
+ min-width: 0;
}
.question-config-tag {
diff --git a/static/js/script.js b/static/js/script.js
index 8412728..ea16ca0 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -10,6 +10,11 @@ document.addEventListener('DOMContentLoaded', () => {
'exclusions',
'target_length_structure',
'tone_stance',
+ 'reader_problem',
+ 'key_takeaway',
+ 'concrete_example',
+ 'evidence',
+ 'title_keywords',
'cor_blog_purpose',
'cor_blog_category',
'cor_blog_metadata',
@@ -268,10 +273,13 @@ document.addEventListener('DOMContentLoaded', () => {
async function submitAnswer() {
clearError();
- const content = el.answerInput.value.trim();
+ let content = el.answerInput.value.trim();
if (!content) {
- showError('回答を入力してください');
- return;
+ if (isQuestionRequired(state.nextQuestion)) {
+ showError('回答を入力してください');
+ return;
+ }
+ content = '未定';
}
state.lastSubmittedAnswer = content;
el.answerInput.value = '';
@@ -494,9 +502,14 @@ document.addEventListener('DOMContentLoaded', () => {
questionBubble.className = 'question-bubble current';
const label = document.createElement('span');
label.className = 'bubble-label';
- label.textContent = question.flow_type === 'deep_dive_follow_up' ? '次の深掘り質問' : '次の質問';
+ label.textContent = pendingQuestionLabel(question);
const text = document.createElement('p');
text.textContent = question.text || '質問を準備しています...';
+ if (question.flow_type !== 'deep_dive_follow_up') {
+ el.answerInput.placeholder = isQuestionRequired(question)
+ ? '短くても大丈夫です。箇条書きでも入力できます。'
+ : '任意です。空のまま送ると「未定」で進みます。';
+ }
questionBubble.append(label, text);
if (question.flow_type === 'deep_dive_follow_up') {
const context = parentContextForQuestion(question);
@@ -508,6 +521,20 @@ document.addEventListener('DOMContentLoaded', () => {
return item;
}
+ function pendingQuestionLabel(question) {
+ if (question.flow_type === 'deep_dive_follow_up') {
+ return '次の深掘り質問';
+ }
+ return isQuestionRequired(question) ? '次の質問' : '次の質問(任意)';
+ }
+
+ function isQuestionRequired(question) {
+ if (!question) {
+ return true;
+ }
+ return question.required !== false && question.Required !== false;
+ }
+
function renderPendingQuestion(existingItem, text) {
if (existingItem) {
const paragraph = existingItem.querySelector('p');
@@ -905,17 +932,16 @@ document.addEventListener('DOMContentLoaded', () => {
const row = document.createElement('div');
row.className = 'question-config-row template';
- const input = document.createElement('input');
- input.type = 'text';
- input.value = question.text;
- input.readOnly = true;
- input.setAttribute('aria-label', 'テンプレート質問');
+ const text = document.createElement('div');
+ text.className = 'question-template-text';
+ text.textContent = question.text;
+ text.setAttribute('aria-label', 'テンプレート質問');
const label = document.createElement('span');
label.className = 'question-config-tag';
- label.textContent = 'テンプレート';
+ label.textContent = isQuestionRequired(question) ? '必須' : '任意';
- row.append(input, label);
+ row.append(text, label);
return row;
}
From 0e1221b77216c68eabce37507b4d9ff5f6533ccb Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 01:54:03 +0900
Subject: [PATCH 22/33] Switch style source by persona and format (#69)
---
...02-multi-persona-multi-format-extension.md | 3 +-
.../next-implementation-cut.md | 3 +-
...-68-media-aware-style-source-2026-05-03.md | 40 +++++
internal/domain/persona/seed.go | 2 +-
internal/handlers/workflow.go | 137 ++++++++++++++++--
internal/handlers/workflow_handler_test.go | 41 +++++-
.../workflow_regenerate_section_test.go | 2 +-
internal/handlers/workflow_stream_test.go | 2 +-
static/index.html | 4 +-
static/js/script.js | 67 +++++++--
10 files changed, 266 insertions(+), 35 deletions(-)
create mode 100644 docs/validation/issue-68-media-aware-style-source-2026-05-03.md
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 6e7fb82..82378d9 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -216,10 +216,11 @@ Current implementation status as of 2026-05-03:
- 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.
- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
+- Style analysis is now persona/format-aware ([#68](https://github.com/terisuke/note_maker/issues/68)): the web UI shows a general `文体ソース` selector instead of `Noteユーザー名`, defaults it to note/Zenn/Qiita/Cor GitHub Markdown based on the selected mode, and makes persona presets include output-format notes. Validation is recorded in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
Near-term execution order:
-1. Browser sanity check for the #66 interview flow — confirm the new smaller questions are easier to answer before spending Evo X2 runtime.
+1. Browser sanity check for the #66/#68 setup — confirm both the smaller questions and the style source change when switching note/Zenn/Qiita/Cor blog modes.
2. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
4. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 34d2578..2d7a9a8 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -34,6 +34,7 @@ Open and active:
- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
- 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).
## Final evaluation target
@@ -81,7 +82,7 @@ Lane A and Lane B can run immediately in parallel. Lane C can start by implement
## Recommended order
-1. Browser-check the #66 smaller question flow with at least note and one technical format. Do this before spending Evo X2 runtime.
+1. Browser-check the #66/#68 setup with note, one technical format, and Cor company blog. Confirm both the question template and `文体ソース` default change before spending Evo X2 runtime.
2. Run one bounded Evo X2 live case through #57 and attach it to #40 to verify the runner with real latency/score data.
3. Start #27 and #28 in parallel so persisted sessions, guides, and draft artifacts become visible in the web app.
4. Start #13 once the history/artifact UI has enough stable browser surface.
diff --git a/docs/validation/issue-68-media-aware-style-source-2026-05-03.md b/docs/validation/issue-68-media-aware-style-source-2026-05-03.md
new file mode 100644
index 0000000..c3d7c98
--- /dev/null
+++ b/docs/validation/issue-68-media-aware-style-source-2026-05-03.md
@@ -0,0 +1,40 @@
+# Issue 68 media-aware style source validation - 2026-05-03
+
+## Problem
+
+The interview question template changed with persona and output format, but the style analysis panel still looked and behaved like a note-only input. That made the company blog mode misleading: users could select `会社ブログ`, see company-blog questions, and still derive style from note unless they manually knew the hidden source selector syntax.
+
+## Implementation
+
+- Renamed the UI input from `Noteユーザー名` to `文体ソース`.
+- The UI now defaults the source selector from the selected persona and output format:
+ - `terisuke + note_article` -> `note:cor_instrument`
+ - `terisuke + markdown_blog` -> `github:Cor-Incorporated/corsweb2024/src/content/blog/ja`
+ - `terisuke + homepage_section` -> `github:Cor-Incorporated/corsweb2024/src/content/blog/ja`
+ - `cloudia + zenn_article` -> `zenn:cloudia`
+ - `cloudia + qiita_article` -> `qiita:Cloudia_Cor_Inc`
+- `POST /api/author-style/analyze` now accepts `persona_id`, `output_format_id`, and `source_selector`.
+- If the user leaves the source selector blank, the server picks the same persona/format-aware default source.
+- Persona presets are now format-aware: the generated preset guide includes output-format notes and uses the appropriate source URL.
+- The Cor company blog persona source now uses the canonical GitHub Markdown path as its `Ref`, so it can be passed directly to the source router.
+
+## Verification
+
+Commands:
+
+```bash
+go test ./internal/handlers ./internal/domain/persona ./internal/infrastructure/source
+node --check static/js/script.js
+git diff --check
+```
+
+Result:
+
+- All checks passed.
+
+## Remaining
+
+Before #40 live Evo X2 scoring, do a browser sanity check that switching the output format changes both:
+
+- the `文体ソース` default,
+- the brief question template.
diff --git a/internal/domain/persona/seed.go b/internal/domain/persona/seed.go
index 05ec76a..20ed34d 100644
--- a/internal/domain/persona/seed.go
+++ b/internal/domain/persona/seed.go
@@ -12,7 +12,7 @@ func Terisuke() Persona {
Sources: []AuthorSource{
{Kind: "note", Ref: "cor_instrument", URL: "https://note.com/cor_instrument/rss"},
{Kind: "rss", Ref: "cor-jp-blog", URL: "https://cor-jp.com/rss.xml"},
- {Kind: "github", Ref: "corsweb2024-blog", URL: "https://github.com/Cor-Incorporated/corsweb2024/tree/main/src/content/blog/ja"},
+ {Kind: "github", Ref: "Cor-Incorporated/corsweb2024/src/content/blog/ja", URL: "https://github.com/Cor-Incorporated/corsweb2024/tree/main/src/content/blog/ja"},
},
VoiceNotes: VoiceNotes{
FirstPerson: []string{"僕", "私"},
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index 73e6f38..0240604 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -58,14 +58,18 @@ func newWorkflowStore() workflowStoreBackend {
}
type analyzeAuthorStyleRequest struct {
- Username string `json:"username"`
- ArticleURLs []string `json:"article_urls"`
- Limit int `json:"limit"`
- StyleModel string `json:"style_model"`
+ Username string `json:"username"`
+ SourceSelector string `json:"source_selector"`
+ ArticleURLs []string `json:"article_urls"`
+ Limit int `json:"limit"`
+ StyleModel string `json:"style_model"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
}
type seedAuthorStyleRequest struct {
- PersonaID string `json:"persona_id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
}
type authorStyleResponse struct {
@@ -203,7 +207,16 @@ func SeedAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", req.PersonaID, http.StatusBadRequest)
return
}
- result, err := buildPresetAuthorStyle(persona)
+ formatID := req.OutputFormatID
+ if strings.TrimSpace(formatID) == "" {
+ formatID = persona.DefaultFormat
+ }
+ format, ok := outputformat.DefaultRegistry().Get(formatID)
+ if !ok {
+ respondWithError(w, "UNKNOWN_OUTPUT_FORMAT", "Output format was not found", formatID, http.StatusBadRequest)
+ return
+ }
+ result, err := buildPresetAuthorStyle(persona, format)
if err != nil {
respondWithError(w, "AUTHOR_STYLE_PRESET_FAILED", "Failed to build persona preset", err.Error(), http.StatusInternalServerError)
return
@@ -215,7 +228,7 @@ func SeedAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, toAuthorStyleResponse(result))
}
-// AnalyzeAuthorStyleHandler analyzes a note author and stores the resulting style assets.
+// AnalyzeAuthorStyleHandler analyzes a writing source and stores the resulting style assets.
func AnalyzeAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
var req analyzeAuthorStyleRequest
if err := decodeJSONRequest(r, &req); err != nil {
@@ -223,9 +236,25 @@ func AnalyzeAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ persona, ok := personadomain.DefaultRegistry().Get(req.PersonaID)
+ if !ok {
+ respondWithError(w, "UNKNOWN_PERSONA", "Persona was not found", req.PersonaID, http.StatusBadRequest)
+ return
+ }
+ formatID := req.OutputFormatID
+ if strings.TrimSpace(formatID) == "" {
+ formatID = persona.DefaultFormat
+ }
+ format, ok := outputformat.DefaultRegistry().Get(formatID)
+ if !ok {
+ respondWithError(w, "UNKNOWN_OUTPUT_FORMAT", "Output format was not found", formatID, http.StatusBadRequest)
+ return
+ }
+ sourceSelector := firstNonEmpty(req.SourceSelector, req.Username, defaultStyleSourceSelector(persona, format))
+
service := authorstyleapp.NewAnalyzeAuthorStyleService(sourcefetch.NewAuthorStyleFetcher(), nil)
result, err := service.Analyze(r.Context(), authorstyleapp.AnalyzeRequest{
- Username: req.Username,
+ Username: sourceSelector,
ArticleURLs: req.ArticleURLs,
Limit: req.Limit,
})
@@ -274,11 +303,89 @@ func refineStyleGuideWithModel(ctx context.Context, result authorstyleapp.Analyz
return generator.Generate(ctx, prompt)
}
-func buildPresetAuthorStyle(persona personadomain.Persona) (authorstyleapp.AnalyzeResult, error) {
+func defaultStyleSourceSelector(persona personadomain.Persona, format outputformat.OutputFormat) string {
+ source := defaultStyleSource(persona, format)
+ if strings.TrimSpace(source.Kind) == "" {
+ return ""
+ }
+ if strings.TrimSpace(source.Ref) != "" {
+ return source.Kind + ":" + source.Ref
+ }
+ if strings.TrimSpace(source.URL) != "" {
+ return source.Kind + ":" + source.URL
+ }
+ return ""
+}
+
+func defaultStyleSource(persona personadomain.Persona, format outputformat.OutputFormat) personadomain.AuthorSource {
+ switch format.ID {
+ case outputformat.IDMarkdownBlog, outputformat.IDHomepageSection:
+ if source, ok := findPersonaSource(persona, "github"); ok {
+ return source
+ }
+ if source, ok := findPersonaSource(persona, "rss"); ok {
+ return source
+ }
+ case outputformat.IDZennArticle:
+ if source, ok := findPersonaSource(persona, "zenn"); ok {
+ return source
+ }
+ case outputformat.IDQiitaArticle:
+ if source, ok := findPersonaSource(persona, "qiita"); ok {
+ return source
+ }
+ case outputformat.IDNoteArticle:
+ if source, ok := findPersonaSource(persona, "note"); ok {
+ return source
+ }
+ }
+ if len(persona.Sources) > 0 {
+ return persona.Sources[0]
+ }
+ return personadomain.AuthorSource{}
+}
+
+func findPersonaSource(persona personadomain.Persona, kind string) (personadomain.AuthorSource, bool) {
+ for _, source := range persona.Sources {
+ if strings.EqualFold(strings.TrimSpace(source.Kind), kind) {
+ return source, true
+ }
+ }
+ return personadomain.AuthorSource{}, false
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if cleaned := strings.TrimSpace(value); cleaned != "" {
+ return cleaned
+ }
+ }
+ return ""
+}
+
+func formatSpecificPresetNote(formatID string) string {
+ switch formatID {
+ case outputformat.IDMarkdownBlog:
+ return "自社ブログでは、技術的な知見の報告、実装判断、検証結果、社員へのビジョン共有を中心に、断定口調で具体的に書く。"
+ case outputformat.IDZennArticle:
+ return "Zennでは、技術の前提、手順、コード、つまずき、検証結果を開発者向けに整理して書く。"
+ case outputformat.IDQiitaArticle:
+ return "Qiitaでは、再現手順、環境、コード例、結果、参考リンクを実用重視で書く。"
+ case outputformat.IDHomepageSection:
+ return "ホームページでは、会社としての信頼、価値提案、次の行動がすぐ伝わる短いHTMLセクションとして書く。"
+ default:
+ return "noteでは、体験、違和感、技術的な試み、読者への提案を読み物として自然につなぐ。"
+ }
+}
+
+func buildPresetAuthorStyle(persona personadomain.Persona, format outputformat.OutputFormat) (authorstyleapp.AnalyzeResult, error) {
fetchedAt := time.Now().UTC()
content := strings.Join([]string{
persona.Description,
persona.VoiceNotes.Tone,
+ format.DisplayName + ": " + format.Description,
+ format.PromptFragment,
+ formatSpecificPresetNote(format.ID),
strings.Join(persona.VoiceNotes.FirstPerson, " "),
strings.Join(persona.VoiceNotes.TitlePatterns, " "),
strings.Repeat(" "+strings.Join(persona.VoiceNotes.AntiPatterns, " "), 2),
@@ -286,13 +393,13 @@ func buildPresetAuthorStyle(persona personadomain.Persona) (authorstyleapp.Analy
if strings.TrimSpace(content) == "" {
content = persona.DisplayName + " writing preset"
}
- articleURL := "preset://" + persona.ID
- if len(persona.Sources) > 0 && strings.TrimSpace(persona.Sources[0].URL) != "" {
- articleURL = persona.Sources[0].URL
+ articleURL := "preset://" + persona.ID + "/" + format.ID
+ if source := defaultStyleSource(persona, format); strings.TrimSpace(source.URL) != "" {
+ articleURL = source.URL
}
article := articledomain.Article{
URL: articleURL,
- Title: persona.DisplayName + " preset",
+ Title: persona.DisplayName + " / " + format.DisplayName + " preset",
Content: strings.Repeat(content+"\n\n", 8),
}
source := authordomain.AuthorSource{
@@ -323,8 +430,8 @@ func buildPresetAuthorStyle(persona personadomain.Persona) (authorstyleapp.Analy
if len(guide.RecurringThemes) == 0 {
guide.RecurringThemes = []string{persona.DisplayName, "技術", "体験"}
}
- guide.ParagraphRhythm = persona.VoiceNotes.Tone
- guide.HeadingGuidance = "出力先の形式に合わせ、読者が流れを追いやすい見出しを置く"
+ guide.ParagraphRhythm = persona.VoiceNotes.Tone + "\n" + formatSpecificPresetNote(format.ID)
+ guide.HeadingGuidance = "出力先「" + format.DisplayName + "」の形式に合わせ、読者が流れを追いやすい見出しを置く"
guide.OpeningPatterns = append([]string(nil), persona.VoiceNotes.TitlePatterns...)
guide.ConclusionPatterns = []string{"読者が次に試せる具体的な一歩で締める"}
guide.Warnings = append([]string{"persona_preset_without_live_fetch"}, persona.VoiceNotes.AntiPatterns...)
diff --git a/internal/handlers/workflow_handler_test.go b/internal/handlers/workflow_handler_test.go
index 28e1fd9..ba81cee 100644
--- a/internal/handlers/workflow_handler_test.go
+++ b/internal/handlers/workflow_handler_test.go
@@ -20,7 +20,7 @@ import (
func TestSeedAuthorStyleHandlerStoresPresetAndGetAuthorStyle(t *testing.T) {
workflowStore = memory.NewWorkflowStore()
- request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{"persona_id":"terisuke"}`))
+ request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{"persona_id":"terisuke","output_format_id":"markdown_blog"}`))
response := httptest.NewRecorder()
SeedAuthorStyleHandler(response, request)
@@ -32,7 +32,7 @@ func TestSeedAuthorStyleHandlerStoresPresetAndGetAuthorStyle(t *testing.T) {
if err := json.NewDecoder(response.Body).Decode(&seeded); err != nil {
t.Fatalf("decode seed response: %v", err)
}
- if seeded.ProfileID == "" || seeded.GuideID == "" || !strings.Contains(seeded.GuideMarkdown, "一人称") {
+ if seeded.ProfileID == "" || seeded.GuideID == "" || !strings.Contains(seeded.GuideMarkdown, "一人称") || !strings.Contains(seeded.GuideMarkdown, "自社ブログ") {
t.Fatalf("unexpected seeded style response: %#v", seeded)
}
@@ -54,6 +54,38 @@ func TestSeedAuthorStyleHandlerStoresPresetAndGetAuthorStyle(t *testing.T) {
}
}
+func TestDefaultStyleSourceSelectorFollowsPersonaAndFormat(t *testing.T) {
+ registry := personadomain.DefaultRegistry()
+ terisuke, ok := registry.Get(personadomain.IDTerisuke)
+ if !ok {
+ t.Fatal("missing terisuke persona")
+ }
+ cloudia, ok := registry.Get(personadomain.IDCloudia)
+ if !ok {
+ t.Fatal("missing cloudia persona")
+ }
+ formats := outputformat.DefaultRegistry()
+
+ tests := []struct {
+ name string
+ persona personadomain.Persona
+ format outputformat.OutputFormat
+ want string
+ }{
+ {"note", terisuke, formats.MustGet(outputformat.IDNoteArticle), "note:cor_instrument"},
+ {"company blog", terisuke, formats.MustGet(outputformat.IDMarkdownBlog), "github:Cor-Incorporated/corsweb2024/src/content/blog/ja"},
+ {"zenn", cloudia, formats.MustGet(outputformat.IDZennArticle), "zenn:cloudia"},
+ {"qiita", cloudia, formats.MustGet(outputformat.IDQiitaArticle), "qiita:Cloudia_Cor_Inc"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := defaultStyleSourceSelector(tt.persona, tt.format); got != tt.want {
+ t.Fatalf("source = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestSeedAuthorStyleHandlerRejectsUnknownPersona(t *testing.T) {
workflowStore = memory.NewWorkflowStore()
request := httptest.NewRequest(http.MethodPost, "/api/author-styles/seed", bytes.NewBufferString(`{"persona_id":"missing"}`))
@@ -142,7 +174,8 @@ func TestAnalyzeAuthorStyleHandlerValidatesRequest(t *testing.T) {
code string
}{
{name: "bad json", body: `{`, status: http.StatusBadRequest, code: "INVALID_REQUEST_FORMAT"},
- {name: "missing source", body: `{}`, status: http.StatusInternalServerError, code: "AUTHOR_STYLE_ANALYSIS_FAILED"},
+ {name: "unknown persona", body: `{"persona_id":"missing"}`, status: http.StatusBadRequest, code: "UNKNOWN_PERSONA"},
+ {name: "unknown format", body: `{"persona_id":"terisuke","output_format_id":"missing"}`, status: http.StatusBadRequest, code: "UNKNOWN_OUTPUT_FORMAT"},
}
for _, tt := range tests {
@@ -658,7 +691,7 @@ func setupWorkflowStyle(t *testing.T) authorstyleapp.AnalyzeResult {
if !ok {
t.Fatal("missing terisuke persona")
}
- style, err := buildPresetAuthorStyle(persona)
+ style, err := buildPresetAuthorStyle(persona, outputformat.DefaultRegistry().MustGet(outputformat.IDNoteArticle))
if err != nil {
t.Fatalf("build style: %v", err)
}
diff --git a/internal/handlers/workflow_regenerate_section_test.go b/internal/handlers/workflow_regenerate_section_test.go
index 63a4958..742b556 100644
--- a/internal/handlers/workflow_regenerate_section_test.go
+++ b/internal/handlers/workflow_regenerate_section_test.go
@@ -31,7 +31,7 @@ func TestRegenerateDraftSectionHandlerReplacesOnlyTargetSection(t *testing.T) {
if !ok {
t.Fatal("missing terisuke persona")
}
- style, err := buildPresetAuthorStyle(persona)
+ style, err := buildPresetAuthorStyle(persona, outputformat.DefaultRegistry().MustGet(outputformat.IDNoteArticle))
if err != nil {
t.Fatalf("build style: %v", err)
}
diff --git a/internal/handlers/workflow_stream_test.go b/internal/handlers/workflow_stream_test.go
index 177776a..ce94e15 100644
--- a/internal/handlers/workflow_stream_test.go
+++ b/internal/handlers/workflow_stream_test.go
@@ -39,7 +39,7 @@ func TestGenerateDraftHandlerStreamsSSE(t *testing.T) {
if !ok {
t.Fatal("missing terisuke persona")
}
- style, err := buildPresetAuthorStyle(persona)
+ style, err := buildPresetAuthorStyle(persona, outputformat.DefaultRegistry().MustGet(outputformat.IDNoteArticle))
if err != nil {
t.Fatalf("build style: %v", err)
}
diff --git a/static/index.html b/static/index.html
index 8efa8f6..d19f325 100644
--- a/static/index.html
+++ b/static/index.html
@@ -95,11 +95,11 @@ 取材質問
1
文体分析 / プリセット
- note記事の分析、または書き手プリセットから再利用できる文体ガイドを作ります。
+ 選択中の書き手と出力先に合わせて、note / Zenn / Qiita / 自社ブログから文体ガイドを作ります。
- Noteユーザー名
+ 文体ソース
diff --git a/static/js/script.js b/static/js/script.js
index ea16ca0..627fc9a 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -182,19 +182,22 @@ document.addEventListener('DOMContentLoaded', () => {
async function analyzeStyle() {
clearError();
- const username = el.username.value.trim();
- if (!username) {
- showError('Noteユーザー名を入力してください');
+ const sourceSelector = el.username.value.trim() || defaultStyleSourceSelector();
+ if (!sourceSelector) {
+ showError('文体ソースを入力してください');
return;
}
- setLoading(true, 'Note記事を取得し、文体を分析しています...');
+ setLoading(true, '選択中の媒体ソースから記事を取得し、文体を分析しています...');
try {
const data = await requestJSON('/api/author-style/analyze', {
method: 'POST',
body: {
- username,
+ username: sourceSelector,
+ source_selector: sourceSelector,
limit: Number(el.limit.value),
style_model: el.styleModel.value,
+ persona_id: currentPersonaId(),
+ output_format_id: currentFormatId(),
},
});
applyStyleResult(data);
@@ -214,6 +217,7 @@ document.addEventListener('DOMContentLoaded', () => {
method: 'POST',
body: {
persona_id: currentPersonaId(),
+ output_format_id: currentFormatId(),
},
});
applyStyleResult(data);
@@ -731,6 +735,7 @@ document.addEventListener('DOMContentLoaded', () => {
function onPersonaChange() {
config.mode.persona = currentPersonaId();
applyPersonaDefaults(true);
+ applyStyleSourceDefault(true);
saveConfig();
renderModeSummary();
loadQuestionTemplate();
@@ -738,6 +743,7 @@ document.addEventListener('DOMContentLoaded', () => {
function onFormatChange() {
config.mode.format = currentFormatId();
+ applyStyleSourceDefault(true);
saveConfig();
renderModeSummary();
loadQuestionTemplate();
@@ -748,14 +754,57 @@ document.addEventListener('DOMContentLoaded', () => {
if (!persona) {
return;
}
- const noteSource = (persona.sources || []).find((source) => source.kind === 'note' && source.ref);
- if (noteSource && el.username.value.trim() === 'cor_instrument') {
- el.username.value = noteSource.ref;
- }
if ((forceFormat || !el.formatSelect.value) && persona.default_format) {
el.formatSelect.value = persona.default_format;
config.mode.format = persona.default_format;
}
+ applyStyleSourceDefault(false);
+ }
+
+ function applyStyleSourceDefault(force) {
+ const source = defaultStyleSourceSelector();
+ if (!source) {
+ return;
+ }
+ const current = el.username.value.trim();
+ if (force || !current || isKnownPersonaSource(current)) {
+ el.username.value = source;
+ }
+ el.username.placeholder = source;
+ }
+
+ function defaultStyleSourceSelector() {
+ const persona = currentPersona();
+ const format = currentFormat();
+ if (!persona || !format) {
+ return '';
+ }
+ const sources = persona.sources || [];
+ const find = (kind) => sources.find((source) => source.kind === kind);
+ let source = null;
+ if (format.id === 'markdown_blog' || format.id === 'homepage_section') {
+ source = find('github') || find('rss');
+ } else if (format.id === 'zenn_article') {
+ source = find('zenn');
+ } else if (format.id === 'qiita_article') {
+ source = find('qiita');
+ } else if (format.id === 'note_article') {
+ source = find('note');
+ }
+ source = source || sources[0];
+ if (!source) {
+ return '';
+ }
+ return source.ref ? `${source.kind}:${source.ref}` : `${source.kind}:${source.url || ''}`;
+ }
+
+ function isKnownPersonaSource(value) {
+ const sources = (state.personas || []).flatMap((persona) => persona.sources || []);
+ return sources.some((source) => {
+ const refSelector = source.ref ? `${source.kind}:${source.ref}` : '';
+ const urlSelector = source.url ? `${source.kind}:${source.url}` : '';
+ return value === source.ref || value === source.url || value === refSelector || value === urlSelector;
+ });
}
function renderModeSummary() {
From 37fa65024c63e04bdb4adfce8e2fd67d02158230 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 03:25:27 +0900
Subject: [PATCH 23/33] docs: decompose issue 40 runtime stabilization (#76)
---
...02-multi-persona-multi-format-extension.md | 11 ++++--
.../issue-adr-guardrails.md | 10 ++++-
.../next-implementation-cut.md | 37 +++++++++++-------
.../issue-40-epic-decomposition-2026-05-03.md | 39 +++++++++++++++++++
4 files changed, 77 insertions(+), 20 deletions(-)
create mode 100644 docs/validation/issue-40-epic-decomposition-2026-05-03.md
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 82378d9..38bc0fc 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -217,13 +217,15 @@ Current implementation status as of 2026-05-03:
- 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.
- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
- Style analysis is now persona/format-aware ([#68](https://github.com/terisuke/note_maker/issues/68)): the web UI shows a general `文体ソース` selector instead of `Noteユーザー名`, defaults it to note/Zenn/Qiita/Cor GitHub Markdown based on the selected mode, and makes persona presets include output-format notes. Validation is recorded in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
+- The 2026-05-03 full Tailnet Evo X2 media-matrix run proved that the runtime path works but also proved that the current scenario is not sufficient as an interview-template acceptance test: only `terisuke_note_essay` passed, Cor blog failed on assistant preamble leakage, Zenn/Qiita failed on cross-format notation leakage, and homepage failed long-form gates despite being a short HTML section. Runtime stabilization is therefore decomposed under epic [#40](https://github.com/terisuke/note_maker/issues/40) into [#70](https://github.com/terisuke/note_maker/issues/70) template/brief scenario coverage, [#71](https://github.com/terisuke/note_maker/issues/71) failed draft artifacts, [#72](https://github.com/terisuke/note_maker/issues/72) bounded format repair, [#73](https://github.com/terisuke/note_maker/issues/73) output-format-specific gates, and [#74](https://github.com/terisuke/note_maker/issues/74) staged Evo X2 reruns.
Near-term execution order:
-1. Browser sanity check for the #66/#68 setup — confirm both the smaller questions and the style source change when switching note/Zenn/Qiita/Cor blog modes.
-2. Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) — expose persisted sessions, guides, briefs, drafts, and verification artifacts in the web app.
-3. Runtime stabilization ([#40](https://github.com/terisuke/note_maker/issues/40)) — first run one bounded media-matrix case through `cmd/scenario/live_media_matrix`, then run the full Note/Qiita/Zenn/Cor blog Evo X2 comparison once the UI can reuse the stored outputs.
-4. Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) — cover persona/format switching, edit/fork, streaming, section regeneration, and persisted-history recovery after C2/C3 has visible browser surface.
+1. Add an interview-template scenario ([#70](https://github.com/terisuke/note_maker/issues/70)) before spending more Evo X2 runtime. It must prove that the simplified questions are small, medium-specific, and able to produce distinct `ArticleBrief` outputs for note, Cor blog, Zenn, Qiita, and homepage.
+2. Make failed generation diagnosable ([#71](https://github.com/terisuke/note_maker/issues/71)) and recoverable when the issue is format-only ([#72](https://github.com/terisuke/note_maker/issues/72)).
+3. Split scenario gates by output format ([#73](https://github.com/terisuke/note_maker/issues/73)) so homepage HTML is not judged as a long article while note/Zenn/Qiita/Cor blog remain strict.
+4. Re-run Evo X2 in stages ([#74](https://github.com/terisuke/note_maker/issues/74)): template scenario, offline media matrix, one previously failing live case, then the full note/Qiita/Zenn/Cor blog comparison.
+5. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel where write scopes do not conflict.
## Tracked issues
@@ -243,6 +245,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
- 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. Sub-issues: [#70](https://github.com/terisuke/note_maker/issues/70), [#71](https://github.com/terisuke/note_maker/issues/71), [#72](https://github.com/terisuke/note_maker/issues/72), [#73](https://github.com/terisuke/note_maker/issues/73), [#74](https://github.com/terisuke/note_maker/issues/74).
## Consequences
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index fe5b069..48e0066 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -21,14 +21,20 @@ Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/00
| [#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. |
-| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. It now owns live runs from `cmd/scenario/media_matrix` across note, Qiita, Zenn, and Cor blog. |
+| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics epic | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. It owns live runs from `cmd/scenario/media_matrix`, but the 2026-05-03 result showed that template usability, failure artifacts, repair, and format-specific gates must land before claiming the full media-matrix result. |
| [#57](https://github.com/terisuke/note_maker/issues/57) | Live media-matrix runner and aggregate evaluator | ADR 0001/0002 runtime validation | Child of #40. Offline mode remains default; live mode must require explicit env vars and must refuse accidental workstation-local fallback for primary Evo X2 validation. |
+| [#70](https://github.com/terisuke/note_maker/issues/70) | Interview-template scenario before Evo X2 media runs | ADR 0002 §Testing Strategy | The question-template change must be tested before draft-only live runs. Scenario output must prove small plain-Japanese questions and medium-specific `ArticleBrief` artifacts. |
+| [#71](https://github.com/terisuke/note_maker/issues/71) | Failed draft artifacts and runtime metrics | ADR 0001/0002 runtime validation | Early validation failures must preserve raw output, elapsed time, endpoint, model, and failure JSON. Do not discard unusable drafts before diagnosis. |
+| [#72](https://github.com/terisuke/note_maker/issues/72) | Bounded format-repair retry | ADR 0002 §Format-specific output | Validators remain strict. One repair retry may be attempted for recoverable preamble or cross-format notation failures, with original and repaired attempts preserved. |
+| [#73](https://github.com/terisuke/note_maker/issues/73) | Output-format-specific scenario gates | ADR 0002 §Testing Strategy | Long-form note/Zenn/Qiita/Cor blog gates stay strict, while homepage HTML uses short-form structure and CTA gates instead of long-article length assumptions. |
+| [#74](https://github.com/terisuke/note_maker/issues/74) | Staged Tailnet Evo X2 validation rerun | ADR 0001/0002 runtime validation | Re-run order is template scenario → offline media matrix → one previously failing live case → full note/Qiita/Zenn/Cor blog live comparison. |
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.
- [#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). Do not close #40 until the staged validation and consecutive-run acceptance criteria are met.
Closed historical issues:
@@ -74,7 +80,7 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) (
- SSH tunnels are allowed only as explicit developer diagnostics, not as the product default, because they depend on per-device SSH setup.
- Local llama.cpp (`http://127.0.0.1:8081/v1`) is fallback only. Do not set `LLM_BASE_URL` to local Ollama or local llama.cpp for Evo X2 validation unless the test is explicitly measuring fallback behavior.
- Runtime validation must report base URL, model, elapsed time, score, and draft length.
- - Each implementation PR that touches interview, prompt, draft, or runtime behavior should add one scenario datapoint with a deliberately varied medium/persona/format. Do not force every PR to rerun every scenario; build averages by collecting one different slice per phase. Use `cmd/scenario/media_matrix` as the canonical matrix for final Note/Qiita/Zenn/Cor blog comparison.
+ - Each implementation PR that touches interview, prompt, draft, or runtime behavior should add one scenario datapoint with a deliberately varied medium/persona/format. If the PR touches question templates, the datapoint must come from the interview-template scenario rather than only draft generation. Do not force every PR to rerun every live scenario; build averages by collecting one different slice per phase. Use `cmd/scenario/media_matrix` as the canonical matrix for final Note/Qiita/Zenn/Cor blog comparison.
- Draft generation must run the lightweight final verification step before returning the final result; if verification reports NEEDS_REVIEW, surface the report instead of hiding it.
- If fallback validation fails the strict draft thresholds, keep Evo X2 primary enabled and track fallback hardening separately (Issue [#36](https://github.com/terisuke/note_maker/issues/36)).
- If Tailnet Evo X2 reaches the API but misses quality gates, track it under Issue [#40](https://github.com/terisuke/note_maker/issues/40), not as a transport regression.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 2d7a9a8..8afd75e 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -30,6 +30,7 @@ Open and active:
- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
+- Runtime evaluation sub-issues: [#70](https://github.com/terisuke/note_maker/issues/70), [#71](https://github.com/terisuke/note_maker/issues/71), [#72](https://github.com/terisuke/note_maker/issues/72), [#73](https://github.com/terisuke/note_maker/issues/73), [#74](https://github.com/terisuke/note_maker/issues/74).
- 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).
- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
@@ -62,11 +63,17 @@ Each live run must record:
## Before the full Evo X2 media run
-The three prerequisites before running the full multi-medium Evo X2 evaluation are now mostly in place:
+The previous prerequisites are in place, but the 2026-05-03 live result exposed a missing layer in the validation plan. A draft-only media matrix cannot prove that the revised question templates are usable, because it starts from completed `ArticleBrief` fixtures.
-1. **Persistence first**: #26 adds SQLite storage for sessions, briefs, source snapshots, drafts, verification, and section-regeneration versions. #61/#62 makes the storage driver visible and switchable from the settings UI, so users do not have to choose it only through make/env setup.
-2. **Handler coverage gate**: #29 raises `internal/handlers` coverage to 80.0%, including SSE, edit/fork, template, regenerate-section, and SQLite driver selection paths.
-3. **Scenario ownership**: #57 adds the reusable live runner/aggregate evaluator. #40 remains the owner for actual Evo X2 Tailnet quality results.
+The runtime stabilization work is now split under epic #40:
+
+| Order | Issue | Purpose | Done when |
+|---:|---|---|---|
+| 1 | [#70](https://github.com/terisuke/note_maker/issues/70) | Add an interview-template scenario | note/Cor blog/Zenn/Qiita/homepage questions and generated briefs differ by mode and remain small enough to answer |
+| 2 | [#71](https://github.com/terisuke/note_maker/issues/71) | Preserve failed draft artifacts | unusable drafts still write raw output, failure JSON, elapsed time, endpoint, and model |
+| 3 | [#72](https://github.com/terisuke/note_maker/issues/72) | Add bounded format repair | preamble leakage and Zenn/Qiita notation leakage get one strict repair retry without relaxing validators |
+| 4 | [#73](https://github.com/terisuke/note_maker/issues/73) | Split scenario gates by output format | homepage uses short HTML gates while long-form media keep strict length/style gates |
+| 5 | [#74](https://github.com/terisuke/note_maker/issues/74) | Re-run staged Evo X2 validation | one previously failing medium passes first, then the full note/Qiita/Zenn/Cor blog live matrix is rerun |
## Parallel implementation plan
@@ -74,21 +81,23 @@ Use subagents with disjoint write scopes:
| Lane | Issue | Subagent role | Write scope | Done when |
|---|---|---|---|---|
-| A | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
-| B | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
-| C | [#40](https://github.com/terisuke/note_maker/issues/40) | Scenario metrics worker | `docs/validation/*`, live run artifacts | media-matrix live runner records endpoint/model/elapsed/score/runes/verification in aggregate JSON/Markdown for actual Evo X2 runs |
+| A | [#70](https://github.com/terisuke/note_maker/issues/70) | Template scenario worker | `cmd/scenario/*`, `internal/domain/brief/*`, validation docs | question-template usability is measured before draft-only live runs |
+| B | [#71](https://github.com/terisuke/note_maker/issues/71) / [#72](https://github.com/terisuke/note_maker/issues/72) | Draft recovery worker | `internal/application/draft/*`, `internal/domain/article/*`, scenario output paths | failed drafts are diagnosable and recoverable format errors get one repair attempt |
+| C | [#73](https://github.com/terisuke/note_maker/issues/73) | Scenario gate worker | `cmd/scenario/*`, validation docs | long-form and homepage gates are explicit and recorded |
+| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
+| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
-Lane A and Lane B can run immediately in parallel. Lane C can start by implementing offline/resumable runner mechanics now, but the full multi-case Evo X2 run should wait until Lane A provides persistence or until the user explicitly wants a one-off artifact-file run.
+Lanes A, B, and C can run in parallel if their write scopes stay separate. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Browser-check the #66/#68 setup with note, one technical format, and Cor company blog. Confirm both the question template and `文体ソース` default change before spending Evo X2 runtime.
-2. Run one bounded Evo X2 live case through #57 and attach it to #40 to verify the runner with real latency/score data.
-3. Start #27 and #28 in parallel so persisted sessions, guides, and draft artifacts become visible in the web app.
-4. Start #13 once the history/artifact UI has enough stable browser surface.
-5. Run the full note/Qiita/Zenn/company-blog media matrix under #40.
+1. Implement #70 first. This proves the revised questions and generated briefs before any more expensive live draft runs.
+2. Implement #71/#72/#73 in parallel where possible. These directly address the failures observed on 2026-05-03.
+3. Run one bounded Evo X2 live case from a previously failing medium, not the already-passing note case.
+4. Start or continue #27/#28 so expensive live outputs can be viewed and reused from the web app.
+5. Run the full note/Qiita/Zenn/company-blog matrix under #74, then update #40 with the aggregate.
6. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
## Why not run the full Evo X2 matrix now?
-The source and prompt matrix is ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. Running all media cases before persistence would produce useful files but not durable product memory. The better sequence is to make the system capable of storing those expensive results, then use #40 to evaluate one varied slice per phase and finally run the full comparison table.
+The source and prompt matrix is ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The 2026-05-03 full run also showed that draft-only evaluation can miss whether interview templates are actually usable. The better sequence is to prove the question-to-brief layer first, preserve failed outputs, repair recoverable format mistakes, then use #40/#74 to evaluate one varied failing slice before the full comparison table.
diff --git a/docs/validation/issue-40-epic-decomposition-2026-05-03.md b/docs/validation/issue-40-epic-decomposition-2026-05-03.md
new file mode 100644
index 0000000..02ddf11
--- /dev/null
+++ b/docs/validation/issue-40-epic-decomposition-2026-05-03.md
@@ -0,0 +1,39 @@
+# Issue 40 epic decomposition
+
+Date: 2026-05-03
+
+## Context
+
+The full Tailnet Evo X2 media-matrix run completed against the primary OpenAI-compatible API path, but only `terisuke_note_essay` passed. The failures were useful, but they also showed that the scenario plan needed one more layer before implementation:
+
+- The draft-only live matrix starts from completed `ArticleBrief` fixtures, so it cannot prove that the revised fixed questions are actually easier to answer.
+- Early unusable drafts were discarded before enough diagnostic artifacts were written.
+- Some failures were recoverable format errors: assistant preamble leakage and Zenn/Qiita notation leakage.
+- The homepage section was judged with long-form article assumptions.
+
+## Epic
+
+[#40](https://github.com/terisuke/note_maker/issues/40) remains open as the runtime stabilization epic. It should not close until staged validation and consecutive-run acceptance criteria are met.
+
+## Sub-issues
+
+| Order | Issue | Scope | Why it exists |
+|---:|---|---|---|
+| 1 | [#70](https://github.com/terisuke/note_maker/issues/70) | Interview-template scenario | Proves question-template usability and medium-specific brief output before Evo X2 draft generation. |
+| 2 | [#71](https://github.com/terisuke/note_maker/issues/71) | Failed draft artifacts | Preserves raw output and runtime metrics when validation fails before a draft is accepted. |
+| 3 | [#72](https://github.com/terisuke/note_maker/issues/72) | Bounded format repair | Gives recoverable format mistakes one strict retry without weakening validators. |
+| 4 | [#73](https://github.com/terisuke/note_maker/issues/73) | Output-format-specific gates | Separates long-form article gates from homepage short HTML gates. |
+| 5 | [#74](https://github.com/terisuke/note_maker/issues/74) | Staged Evo X2 rerun | Runs template/offline/live validation in the correct order and records results back to #40. |
+
+## Implementation order
+
+1. Implement #70.
+2. Implement #71/#72/#73 in parallel only if write scopes stay disjoint.
+3. Run one live Evo X2 case from a previously failing medium.
+4. Run the full note/Qiita/Zenn/Cor blog matrix only after the scenario and diagnostic gaps are closed.
+
+## Docs updated
+
+- [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md)
+- [Issue and ADR guardrails](../implementation-plans/issue-adr-guardrails.md)
+- [Next implementation cut](../implementation-plans/next-implementation-cut.md)
From eb917e2cdf333457c5f33c2816ac1574866fd13f Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 12:13:54 +0900
Subject: [PATCH 24/33] feat: stabilize issue 40 scenario gates (#77)
---
cmd/scenario/draft_generation/main.go | 129 +++-
cmd/scenario/draft_generation/main_test.go | 93 +++
cmd/scenario/interview_template/main.go | 602 ++++++++++++++++++
cmd/scenario/interview_template/main_test.go | 106 +++
cmd/scenario/live_media_matrix/main.go | 263 ++++++--
cmd/scenario/live_media_matrix/main_test.go | 170 +++++
cmd/scenario/media_matrix/main.go | 138 +++-
cmd/scenario/media_matrix/main_test.go | 82 +++
...-interview-template-scenario-2026-05-03.md | 56 ++
...d-draft-artifacts-and-repair-2026-05-03.md | 69 ++
...issue-73-output-format-gates-2026-05-03.md | 72 +++
...issue-74-staged-evo-x2-rerun-2026-05-03.md | 54 ++
...matrix-integrated-evaluation-2026-05-03.md | 59 +-
internal/application/draft/prompt.go | 35 +
internal/application/draft/service.go | 92 ++-
internal/application/draft/service_test.go | 114 +++-
internal/application/draft/types.go | 9 +
internal/domain/article/draft.go | 4 +-
internal/domain/article/draft_test.go | 16 +
internal/domain/format/format.go | 32 +-
internal/domain/format/format_test.go | 37 ++
21 files changed, 2138 insertions(+), 94 deletions(-)
create mode 100644 cmd/scenario/draft_generation/main_test.go
create mode 100644 cmd/scenario/interview_template/main.go
create mode 100644 cmd/scenario/interview_template/main_test.go
create mode 100644 cmd/scenario/live_media_matrix/main_test.go
create mode 100644 cmd/scenario/media_matrix/main_test.go
create mode 100644 docs/validation/issue-70-interview-template-scenario-2026-05-03.md
create mode 100644 docs/validation/issue-71-72-failed-draft-artifacts-and-repair-2026-05-03.md
create mode 100644 docs/validation/issue-73-output-format-gates-2026-05-03.md
create mode 100644 docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
diff --git a/cmd/scenario/draft_generation/main.go b/cmd/scenario/draft_generation/main.go
index 13e35e6..5cc5d9b 100644
--- a/cmd/scenario/draft_generation/main.go
+++ b/cmd/scenario/draft_generation/main.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -42,6 +43,13 @@ func main() {
baseURL := envFirst("http://127.0.0.1:8081/v1", "LLM_BASE_URL", "LLAMACPP_BASE_URL")
model := envFirst("gemma4:31b", "DRAFT_LLM_MODEL", "LLM_MODEL", "LLAMACPP_MODEL")
verifyModel := envFirst("gemma4:latest", "VERIFY_LLM_MODEL", "LLM_MODEL", "LLAMACPP_MODEL")
+ failureContext := failureAttemptContext{
+ LLMBaseURL: baseURL,
+ LLMModel: model,
+ VerifyModel: verifyModel,
+ PersonaID: brief.PersonaID,
+ OutputFormatID: brief.OutputFormatID,
+ }
minStyleScore := envFloat("SCENARIO_MIN_STYLE_SCORE", 80)
minDraftRunes := envInt("SCENARIO_MIN_DRAFT_RUNES", 2400)
maxAttempts := envInt("DRAFT_MAX_ATTEMPTS", 2)
@@ -87,13 +95,24 @@ func main() {
}
elapsed := time.Since(started)
cancel()
+ metrics := attemptRuntimeMetrics{
+ ElapsedSeconds: elapsed.Seconds(),
+ TimeoutSeconds: timeout.Seconds(),
+ Streaming: streamDraft,
+ FirstChunkMs: finalFirstChunkMs(firstChunk),
+ Chunks: chunkCount,
+ }
if err != nil {
- fatalf("generate draft attempt %d: %v", attempt, err)
+ attempts := generationAttemptsFromError(err)
+ artifacts := writeRawAttemptArtifacts(outputDir, attempt, attempts)
+ failurePath := writeFailureAttempt(outputDir, attempt, err, metrics, failureContext, artifacts)
+ fatalf("generate draft attempt %d: %v (failure=%s)", attempt, err, failurePath)
}
finalElapsed = elapsed
finalFirstChunk = firstChunk
finalChunks = chunkCount
finalAttempt = attempt
+ writeRawAttemptArtifacts(outputDir, attempt, result.Attempts)
writeFile(filepath.Join(outputDir, fmt.Sprintf("draft_attempt_%d.md", attempt)), result.Draft.Markdown()+"\n")
writeJSON(filepath.Join(outputDir, fmt.Sprintf("evaluation_attempt_%d.json", attempt)), result.Evaluation)
writeJSON(filepath.Join(outputDir, fmt.Sprintf("verification_attempt_%d.json", attempt)), result.Verification)
@@ -139,6 +158,114 @@ func main() {
}
}
+type attemptRuntimeMetrics struct {
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ TimeoutSeconds float64 `json:"timeout_seconds"`
+ Streaming bool `json:"streaming"`
+ FirstChunkMs int64 `json:"first_chunk_ms,omitempty"`
+ Chunks int `json:"chunks,omitempty"`
+}
+
+type rawAttemptArtifact struct {
+ GenerationAttempt int `json:"generation_attempt"`
+ Kind string `json:"kind"`
+ Path string `json:"path"`
+ ValidationError string `json:"validation_error,omitempty"`
+}
+
+type failureAttemptContext struct {
+ LLMBaseURL string `json:"llm_base_url"`
+ LLMModel string `json:"llm_model"`
+ VerifyModel string `json:"verify_model"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+}
+
+type failureAttemptReport struct {
+ Attempt int `json:"attempt"`
+ Error string `json:"error"`
+ ValidationError string `json:"validation_error,omitempty"`
+ RuntimeMetrics attemptRuntimeMetrics `json:"runtime_metrics"`
+ Context failureAttemptContext `json:"context"`
+ RawOutputs []rawAttemptArtifact `json:"raw_outputs"`
+}
+
+func generationAttemptsFromError(err error) []draftapp.GenerationAttempt {
+ var unusable *draftapp.UnusableDraftError
+ if errors.As(err, &unusable) {
+ return unusable.Attempts
+ }
+ return nil
+}
+
+func writeRawAttemptArtifacts(outputDir string, scenarioAttempt int, attempts []draftapp.GenerationAttempt) []rawAttemptArtifact {
+ artifacts := make([]rawAttemptArtifact, 0, len(attempts))
+ for _, attempt := range attempts {
+ if strings.TrimSpace(attempt.RawOutput) == "" {
+ continue
+ }
+ kind := sanitizeArtifactPart(attempt.Kind)
+ if kind == "" {
+ kind = "generation"
+ }
+ index := attempt.Index
+ if index <= 0 {
+ index = len(artifacts) + 1
+ }
+ path := filepath.Join(outputDir, fmt.Sprintf("raw_attempt_%d_generation_%d_%s.txt", scenarioAttempt, index, kind))
+ writeFile(path, strings.TrimRight(attempt.RawOutput, "\n")+"\n")
+ artifacts = append(artifacts, rawAttemptArtifact{
+ GenerationAttempt: index,
+ Kind: attempt.Kind,
+ Path: path,
+ ValidationError: attempt.ValidationError,
+ })
+ }
+ return artifacts
+}
+
+func writeFailureAttempt(outputDir string, attempt int, err error, metrics attemptRuntimeMetrics, context failureAttemptContext, artifacts []rawAttemptArtifact) string {
+ report := failureAttemptReport{
+ Attempt: attempt,
+ Error: err.Error(),
+ ValidationError: validationErrorFromGenerateError(err),
+ RuntimeMetrics: metrics,
+ Context: context,
+ RawOutputs: artifacts,
+ }
+ path := filepath.Join(outputDir, fmt.Sprintf("failure_attempt_%d.json", attempt))
+ writeJSON(path, report)
+ return path
+}
+
+func validationErrorFromGenerateError(err error) string {
+ var unusable *draftapp.UnusableDraftError
+ if errors.As(err, &unusable) && unusable.Err != nil {
+ return unusable.Err.Error()
+ }
+ return ""
+}
+
+func finalFirstChunkMs(firstChunk time.Duration) int64 {
+ if firstChunk <= 0 {
+ return 0
+ }
+ return firstChunk.Milliseconds()
+}
+
+func sanitizeArtifactPart(value string) string {
+ value = strings.TrimSpace(strings.ToLower(value))
+ var builder strings.Builder
+ for _, r := range value {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
+ builder.WriteRune(r)
+ continue
+ }
+ builder.WriteByte('_')
+ }
+ return strings.Trim(builder.String(), "_")
+}
+
func readJSON(path string, out any) {
encoded, err := os.ReadFile(path)
if err != nil {
diff --git a/cmd/scenario/draft_generation/main_test.go b/cmd/scenario/draft_generation/main_test.go
new file mode 100644
index 0000000..5e938d1
--- /dev/null
+++ b/cmd/scenario/draft_generation/main_test.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+)
+
+func TestWriteFailureAttemptPreservesRawOutputsAndRuntimeMetrics(t *testing.T) {
+ outputDir := t.TempDir()
+ generateErr := &draftapp.UnusableDraftError{
+ FormatID: "zenn_article",
+ Err: errors.New("zenn article must use :::message, not Qiita :::note"),
+ Attempts: []draftapp.GenerationAttempt{
+ {
+ Index: 1,
+ Kind: "initial",
+ RawOutput: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n:::note info\nwrong\n:::",
+ ValidationError: "zenn article must use :::message, not Qiita :::note",
+ },
+ {
+ Index: 2,
+ Kind: "format_repair",
+ RawOutput: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n:::note warn\nstill wrong\n:::",
+ ValidationError: "zenn article must use :::message, not Qiita :::note",
+ },
+ },
+ }
+ metrics := attemptRuntimeMetrics{
+ ElapsedSeconds: 1.25,
+ TimeoutSeconds: 30,
+ Streaming: true,
+ FirstChunkMs: 120,
+ Chunks: 3,
+ }
+ context := failureAttemptContext{
+ LLMBaseURL: "http://evo-x2.tailb30e58.ts.net/v1",
+ LLMModel: "gemma4:31b",
+ VerifyModel: "gemma4:latest",
+ PersonaID: "cloudia",
+ OutputFormatID: "zenn_article",
+ }
+
+ artifacts := writeRawAttemptArtifacts(outputDir, 2, generationAttemptsFromError(generateErr))
+ failurePath := writeFailureAttempt(outputDir, 2, generateErr, metrics, context, artifacts)
+
+ if len(artifacts) != 2 {
+ t.Fatalf("artifacts = %#v, want 2", artifacts)
+ }
+ for _, artifact := range artifacts {
+ content, err := os.ReadFile(artifact.Path)
+ if err != nil {
+ t.Fatalf("read raw artifact %s: %v", artifact.Path, err)
+ }
+ if len(content) == 0 {
+ t.Fatalf("raw artifact %s was empty", artifact.Path)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(outputDir, "raw_attempt_2_generation_1_initial.txt")); err != nil {
+ t.Fatalf("missing initial raw artifact: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(outputDir, "raw_attempt_2_generation_2_format_repair.txt")); err != nil {
+ t.Fatalf("missing repair raw artifact: %v", err)
+ }
+
+ encoded, err := os.ReadFile(failurePath)
+ if err != nil {
+ t.Fatalf("read failure artifact: %v", err)
+ }
+ var report failureAttemptReport
+ if err := json.Unmarshal(encoded, &report); err != nil {
+ t.Fatalf("decode failure artifact: %v", err)
+ }
+ if report.Attempt != 2 {
+ t.Fatalf("attempt = %d, want 2", report.Attempt)
+ }
+ if report.ValidationError != "zenn article must use :::message, not Qiita :::note" {
+ t.Fatalf("validation error = %q", report.ValidationError)
+ }
+ if report.RuntimeMetrics.ElapsedSeconds != 1.25 || report.RuntimeMetrics.FirstChunkMs != 120 || report.RuntimeMetrics.Chunks != 3 {
+ t.Fatalf("runtime metrics not preserved: %#v", report.RuntimeMetrics)
+ }
+ if report.Context.LLMBaseURL != context.LLMBaseURL || report.Context.LLMModel != context.LLMModel || report.Context.OutputFormatID != context.OutputFormatID {
+ t.Fatalf("runtime context not preserved: %#v", report.Context)
+ }
+ if len(report.RawOutputs) != 2 || report.RawOutputs[0].ValidationError == "" {
+ t.Fatalf("raw output metadata not preserved: %#v", report.RawOutputs)
+ }
+}
diff --git a/cmd/scenario/interview_template/main.go b/cmd/scenario/interview_template/main.go
new file mode 100644
index 0000000..59f4507
--- /dev/null
+++ b/cmd/scenario/interview_template/main.go
@@ -0,0 +1,602 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ briefapp "github.com/teradakousuke/note_maker/internal/application/brief"
+ 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"
+)
+
+const defaultOutputDir = "tmp/interview_template"
+
+type scenarioReport struct {
+ GeneratedBy string `json:"generated_by"`
+ OfflineOnly bool `json:"offline_only"`
+ Cases []caseResult `json:"cases"`
+ RequiredBriefFields []string `json:"required_brief_fields"`
+ ExpectedDeepDiveTarget []string `json:"expected_deep_dive_target_order"`
+ QuestionCoverage map[string]int `json:"question_coverage"`
+ BriefCoverage map[string]int `json:"brief_coverage"`
+ Artifacts map[string]string `json:"artifacts"`
+}
+
+type casePlan struct {
+ ID string
+ PersonaID string
+ OutputFormatID string
+}
+
+type caseResult struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ PersonaDisplayName string `json:"persona_display_name"`
+ OutputFormatID string `json:"output_format_id"`
+ OutputFormatName string `json:"output_format_name"`
+ SessionID string `json:"session_id"`
+ QuestionCount int `json:"question_count"`
+ RequiredQuestionIDs []string `json:"required_question_ids"`
+ OptionalQuestionIDs []string `json:"optional_question_ids"`
+ ExtensionQuestionIDs []string `json:"extension_question_ids"`
+ CustomAnswerIDs []string `json:"custom_answer_ids"`
+ DeepDiveTargetIDs []string `json:"deep_dive_target_ids"`
+ DeepDiveCount int `json:"deep_dive_count"`
+ TemplateChecks []checkResult `json:"template_checks"`
+ BriefChecks []checkResult `json:"brief_checks"`
+ QuestionTemplate []questionEntry `json:"question_template"`
+ BriefPath string `json:"brief_path"`
+ SessionPath string `json:"session_path"`
+}
+
+type questionEntry struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Required bool `json:"required"`
+ TargetField string `json:"target_field"`
+}
+
+type checkResult struct {
+ Name string `json:"name"`
+ Passed bool `json:"passed"`
+ Detail string `json:"detail,omitempty"`
+}
+
+func main() {
+ outputDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)
+ report, err := runScenario(context.Background(), outputDir)
+ if err != nil {
+ fatalf("%v", err)
+ }
+
+ fmt.Printf("interview template scenario completed\n")
+ fmt.Printf("offline_only=%v\n", report.OfflineOnly)
+ fmt.Printf("cases=%d\n", len(report.Cases))
+ fmt.Printf("question_templates=%d\n", report.QuestionCoverage["cases"])
+ fmt.Printf("briefs=%d\n", report.BriefCoverage["cases"])
+ fmt.Printf("report=%s\n", report.Artifacts["report"])
+ fmt.Printf("cases_markdown=%s\n", report.Artifacts["cases_markdown"])
+}
+
+func runScenario(ctx context.Context, outputDir string) (scenarioReport, error) {
+ outputDir = strings.TrimSpace(outputDir)
+ if outputDir == "" {
+ outputDir = defaultOutputDir
+ }
+ briefDir := filepath.Join(outputDir, "briefs")
+ sessionDir := filepath.Join(outputDir, "sessions")
+ for _, dir := range []string{outputDir, briefDir, sessionDir} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return scenarioReport{}, fmt.Errorf("create output dir %s: %w", dir, err)
+ }
+ }
+
+ personas := personadomain.DefaultRegistry()
+ formats := outputformat.DefaultRegistry()
+ service := briefapp.NewInterviewService(nil)
+
+ results := make([]caseResult, 0)
+ for _, plan := range scenarioPlans(personas.List(), formats.List()) {
+ result, err := runCase(ctx, service, personas, formats, plan, briefDir, sessionDir)
+ if err != nil {
+ return scenarioReport{}, err
+ }
+ results = append(results, result)
+ }
+ if len(results) == 0 {
+ return scenarioReport{}, fmt.Errorf("no interview template cases were generated")
+ }
+
+ reportPath := filepath.Join(outputDir, "report.json")
+ casesPath := filepath.Join(outputDir, "cases.md")
+ report := scenarioReport{
+ GeneratedBy: "cmd/scenario/interview_template",
+ OfflineOnly: true,
+ Cases: results,
+ RequiredBriefFields: requiredBriefFields(),
+ ExpectedDeepDiveTarget: expectedDeepDiveTargets(),
+ QuestionCoverage: questionCoverage(results),
+ BriefCoverage: briefCoverage(results),
+ Artifacts: map[string]string{
+ "report": reportPath,
+ "cases_markdown": casesPath,
+ "briefs": briefDir,
+ "sessions": sessionDir,
+ },
+ }
+ if err := writeJSON(reportPath, report); err != nil {
+ return scenarioReport{}, err
+ }
+ if err := writeFile(casesPath, casesMarkdown(report)); err != nil {
+ return scenarioReport{}, err
+ }
+ return report, nil
+}
+
+func scenarioPlans(personas []personadomain.Persona, formats []outputformat.OutputFormat) []casePlan {
+ plans := make([]casePlan, 0, len(personas)*len(formats))
+ for _, persona := range personas {
+ for _, format := range formats {
+ plans = append(plans, casePlan{
+ ID: persona.ID + "_" + format.ID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ })
+ }
+ }
+ return plans
+}
+
+func runCase(ctx context.Context, service *briefapp.InterviewService, personas personadomain.Registry, formats outputformat.Registry, plan casePlan, briefDir, sessionDir string) (caseResult, error) {
+ persona, ok := personas.Get(plan.PersonaID)
+ if !ok {
+ return caseResult{}, fmt.Errorf("%s references unknown persona %s", plan.ID, plan.PersonaID)
+ }
+ format, ok := formats.Get(plan.OutputFormatID)
+ if !ok {
+ return caseResult{}, fmt.Errorf("%s references unknown output format %s", plan.ID, plan.OutputFormatID)
+ }
+ sessionID := "interview_template_" + plan.ID
+ result, err := service.StartSession(briefapp.StartSessionInput{
+ SessionID: sessionID,
+ StyleProfileID: "style_interview_template",
+ PersonaID: plan.PersonaID,
+ OutputFormatID: plan.OutputFormatID,
+ })
+ if err != nil {
+ return caseResult{}, fmt.Errorf("%s start session: %w", plan.ID, err)
+ }
+
+ for !result.Completed {
+ if result.NextQuestion == nil {
+ return caseResult{}, fmt.Errorf("%s has no next question before completion", plan.ID)
+ }
+ question := *result.NextQuestion
+ answer := scriptedAnswer(plan, persona, format, question)
+ result, err = service.Answer(ctx, result.Session, answer)
+ if err != nil {
+ return caseResult{}, fmt.Errorf("%s answer %s: %w", plan.ID, question.ID, err)
+ }
+ }
+ if result.Brief == nil {
+ return caseResult{}, fmt.Errorf("%s completed without an article brief", plan.ID)
+ }
+
+ briefPath := filepath.Join(briefDir, plan.ID+".json")
+ sessionPath := filepath.Join(sessionDir, plan.ID+".json")
+ if err := writeJSON(briefPath, result.Brief); err != nil {
+ return caseResult{}, err
+ }
+ if err := writeJSON(sessionPath, result.Session); err != nil {
+ return caseResult{}, err
+ }
+
+ requiredIDs, optionalIDs := splitRequiredQuestionIDs(result.Session.Questions)
+ customIDs := customAnswerIDs(result.Brief.CustomAnswers)
+ deepDiveTargets := deepDiveTargetIDs(result.Brief.DeepDives)
+ return caseResult{
+ ID: plan.ID,
+ PersonaID: persona.ID,
+ PersonaDisplayName: persona.DisplayName,
+ OutputFormatID: format.ID,
+ OutputFormatName: format.DisplayName,
+ SessionID: result.Session.ID,
+ QuestionCount: len(result.Session.Questions),
+ RequiredQuestionIDs: requiredIDs,
+ OptionalQuestionIDs: optionalIDs,
+ ExtensionQuestionIDs: extensionQuestionIDs(result.Session.Questions),
+ CustomAnswerIDs: customIDs,
+ DeepDiveTargetIDs: deepDiveTargets,
+ DeepDiveCount: len(result.Brief.DeepDives),
+ TemplateChecks: templateChecks(plan, result.Session.Questions),
+ BriefChecks: briefChecks(plan, *result.Brief, customIDs, deepDiveTargets),
+ QuestionTemplate: questionTemplate(result.Session.Questions),
+ BriefPath: briefPath,
+ SessionPath: sessionPath,
+ }, nil
+}
+
+func templateChecks(plan casePlan, questions []briefdomain.ArticleQuestion) []checkResult {
+ checks := []checkResult{
+ check("unique_question_ids", uniqueQuestionIDs(questions), ""),
+ check("base_questions_present", containsAllQuestionIDs(questions, baseQuestionIDs()), ""),
+ }
+ for _, id := range expectedExtensionQuestionIDs(plan.PersonaID, plan.OutputFormatID) {
+ checks = append(checks, check("extension_"+id, containsQuestionID(questions, id), ""))
+ }
+ return checks
+}
+
+func briefChecks(plan casePlan, brief briefdomain.ArticleBrief, customIDs, deepDiveTargets []string) []checkResult {
+ checks := []checkResult{
+ check("persona_id", brief.PersonaID == plan.PersonaID, brief.PersonaID),
+ check("output_format_id", brief.OutputFormatID == plan.OutputFormatID, brief.OutputFormatID),
+ check("required_fields", hasRequiredBriefFields(brief), ""),
+ check("deep_dive_count", len(brief.DeepDives) == briefdomain.MaxTotalFollowUps, fmt.Sprintf("%d", len(brief.DeepDives))),
+ check("deep_dive_targets", strings.Join(deepDiveTargets, ",") == strings.Join(expectedDeepDiveTargets(), ","), strings.Join(deepDiveTargets, ",")),
+ }
+ for _, id := range expectedExtensionQuestionIDs(plan.PersonaID, plan.OutputFormatID) {
+ checks = append(checks, check("custom_answer_"+id, containsString(customIDs, id), ""))
+ }
+ return checks
+}
+
+func scriptedAnswer(plan casePlan, persona personadomain.Persona, format outputformat.OutputFormat, question briefdomain.ArticleQuestion) string {
+ if question.FlowType == briefdomain.QuestionFlowDeepDiveFollowUp {
+ return deepDiveAnswer(question)
+ }
+ switch question.ID {
+ case briefdomain.QuestionIDTheme:
+ return fmt.Sprintf("%s向けに%sの発信テンプレートを検証する", format.DisplayName, persona.DisplayName)
+ case briefdomain.QuestionIDOpeningEpisode:
+ return "ライブ生成の前に、質問テンプレートだけを固定入力で確認した場面から始める"
+ case briefdomain.QuestionIDReader:
+ return "Evo X2で媒体別の記事生成を試す開発者とレビュー担当者"
+ case briefdomain.QuestionIDReaderProblem:
+ return "生成前に、媒体や人格ごとの聞き取り内容が混ざっていないか判断しづらい"
+ case briefdomain.QuestionIDExpectedReaderAction:
+ return "ライブ下書き生成の前に、このオフラインシナリオを必ず通す"
+ case briefdomain.QuestionIDKeyTakeaway:
+ return "テンプレートとブリーフを先に固定すると、LLM評価の前提が揃う"
+ case briefdomain.QuestionIDMustInclude:
+ return "ペルソナID、出力形式ID、追加質問、深掘り回答、完成ArticleBriefの保存先"
+ case briefdomain.QuestionIDConcreteExample:
+ return "てりすけのnote、クラウディアのZenn、会社ブログ、Qiita、HTMLセクションを同じ規則で確認する"
+ case briefdomain.QuestionIDEvidence:
+ return "10ケース、全ケース4件の深掘り、必須フィールドと追加回答の存在をJSONで記録する"
+ case briefdomain.QuestionIDPersonalContext:
+ return "媒体別のライブ実測に入る前に、入力条件の揺れをなくしたい"
+ case briefdomain.QuestionIDExclusions:
+ return "ネットワークアクセス、LLM呼び出し、実下書き生成、実測値の比較"
+ case briefdomain.QuestionIDTargetLengthStructure:
+ return "1200字相当。導入、確認対象、ケース、検証結果、次のライブ実行条件で構成する"
+ case briefdomain.QuestionIDToneStance:
+ return persona.PromptHint()
+ case briefdomain.QuestionIDTitleKeywords:
+ return "interview template, ArticleBrief, Evo X2 preflight"
+ case briefdomain.QuestionIDStoryArc:
+ return "違和感から始め、固定入力で確認し、ライブ生成へ進む順番にする"
+ case briefdomain.QuestionIDTargetStack:
+ return "Go 1.23、cmd/scenario/interview_template、internal/domain/brief"
+ case briefdomain.QuestionIDPrerequisiteKnowledge:
+ return "GoのテストとJSON成果物を読める開発者を前提にする"
+ case briefdomain.QuestionIDTechnicalProof:
+ return "go testとgo runの成功、report.jsonのチェック結果を根拠にする"
+ case briefdomain.QuestionIDCodeExamples:
+ return "必要ならgo run ./cmd/scenario/interview_templateの実行例だけ載せる"
+ case briefdomain.QuestionIDReferences:
+ return "docs/validation/issue-70-interview-template-scenario-2026-05-03.md"
+ case briefdomain.QuestionIDCorBlogPurpose:
+ return "技術知見の報告として、ライブ測定の前提条件を社内外に共有する"
+ case briefdomain.QuestionIDCorBlogNextAction:
+ return "ライブ媒体マトリクスを実行する前にオフラインpreflightを確認してほしい"
+ case briefdomain.QuestionIDHomepageCTA:
+ return "検証済みブリーフを確認してからライブ生成へ進む"
+ case briefdomain.QuestionIDHomepageTrust:
+ return "全ケースをオフラインで再現でき、LLMやネットワークに依存しない"
+ case briefdomain.QuestionIDCloudiaViewpoint:
+ return "媒体ごとの質問が切り替わる様子を、初心者にも楽しく見える確認として扱う"
+ default:
+ return "この質問はシナリオの固定回答で検証する"
+ }
+}
+
+func deepDiveAnswer(question briefdomain.ArticleQuestion) string {
+ switch question.TargetQuestionID {
+ case briefdomain.QuestionIDOpeningEpisode:
+ if question.FollowUpIndex == 1 {
+ return "最初に見せたいのは、ライブ実行前でも全ケースの質問とブリーフが揃う画面"
+ }
+ return "その時の気持ちは、測る前に入力を固められて安心したという感覚"
+ case briefdomain.QuestionIDMustInclude:
+ if question.FollowUpIndex == 1 {
+ return "特に詳しく説明したいのは、追加質問がCustomAnswersへ残ること"
+ }
+ return "根拠としてreport.jsonと各brief JSONを残す"
+ default:
+ return "記事に足す具体情報として、オフラインで再実行できるコマンドを入れる"
+ }
+}
+
+func expectedExtensionQuestionIDs(personaID, formatID string) []string {
+ ids := make([]string, 0)
+ switch formatID {
+ case outputformat.IDNoteArticle:
+ ids = append(ids, briefdomain.QuestionIDStoryArc)
+ case outputformat.IDMarkdownBlog:
+ ids = append(ids, technicalQuestionIDs()...)
+ ids = append(ids, briefdomain.QuestionIDCorBlogPurpose, briefdomain.QuestionIDCorBlogNextAction)
+ case outputformat.IDZennArticle, outputformat.IDQiitaArticle:
+ ids = append(ids, technicalQuestionIDs()...)
+ case outputformat.IDHomepageSection:
+ ids = append(ids, briefdomain.QuestionIDHomepageCTA, briefdomain.QuestionIDHomepageTrust)
+ }
+ if personaID == personadomain.IDCloudia {
+ ids = append(ids, briefdomain.QuestionIDCloudiaViewpoint)
+ }
+ return ids
+}
+
+func technicalQuestionIDs() []string {
+ return []string{
+ briefdomain.QuestionIDTargetStack,
+ briefdomain.QuestionIDPrerequisiteKnowledge,
+ briefdomain.QuestionIDTechnicalProof,
+ briefdomain.QuestionIDCodeExamples,
+ briefdomain.QuestionIDReferences,
+ }
+}
+
+func baseQuestionIDs() []string {
+ questions := briefdomain.FixedQuestions()
+ ids := make([]string, 0, len(questions))
+ for _, question := range questions {
+ ids = append(ids, question.ID)
+ }
+ return ids
+}
+
+func expectedDeepDiveTargets() []string {
+ return []string{
+ briefdomain.QuestionIDOpeningEpisode,
+ briefdomain.QuestionIDOpeningEpisode,
+ briefdomain.QuestionIDMustInclude,
+ briefdomain.QuestionIDMustInclude,
+ }
+}
+
+func requiredBriefFields() []string {
+ return []string{
+ "style_profile_id",
+ "persona_id",
+ "output_format_id",
+ "theme",
+ "opening_episode",
+ "reader",
+ "expected_reader_action",
+ "must_include",
+ "personal_context",
+ "target_length_structure",
+ "tone_stance",
+ }
+}
+
+func hasRequiredBriefFields(brief briefdomain.ArticleBrief) bool {
+ values := []string{
+ brief.StyleProfileID,
+ brief.PersonaID,
+ brief.OutputFormatID,
+ brief.Theme,
+ brief.OpeningEpisode,
+ brief.Reader,
+ brief.ExpectedReaderAction,
+ brief.MustInclude,
+ brief.PersonalContext,
+ brief.TargetLengthStructure,
+ brief.ToneStance,
+ }
+ for _, value := range values {
+ if strings.TrimSpace(value) == "" {
+ return false
+ }
+ }
+ return true
+}
+
+func splitRequiredQuestionIDs(questions []briefdomain.ArticleQuestion) ([]string, []string) {
+ required := make([]string, 0)
+ optional := make([]string, 0)
+ for _, question := range questions {
+ if question.FlowType != briefdomain.QuestionFlowMain {
+ continue
+ }
+ if question.Required {
+ required = append(required, question.ID)
+ continue
+ }
+ optional = append(optional, question.ID)
+ }
+ return required, optional
+}
+
+func extensionQuestionIDs(questions []briefdomain.ArticleQuestion) []string {
+ base := map[string]bool{}
+ for _, id := range baseQuestionIDs() {
+ base[id] = true
+ }
+ ids := make([]string, 0)
+ for _, question := range questions {
+ if question.FlowType == briefdomain.QuestionFlowMain && !base[question.ID] {
+ ids = append(ids, question.ID)
+ }
+ }
+ return ids
+}
+
+func questionTemplate(questions []briefdomain.ArticleQuestion) []questionEntry {
+ entries := make([]questionEntry, 0, len(questions))
+ for _, question := range questions {
+ if question.FlowType != briefdomain.QuestionFlowMain {
+ continue
+ }
+ entries = append(entries, questionEntry{
+ ID: question.ID,
+ Text: question.Text,
+ Required: question.Required,
+ TargetField: question.TargetField,
+ })
+ }
+ return entries
+}
+
+func customAnswerIDs(answers []briefdomain.BriefAnswer) []string {
+ ids := make([]string, 0, len(answers))
+ for _, answer := range answers {
+ ids = append(ids, answer.QuestionID)
+ }
+ return ids
+}
+
+func deepDiveTargetIDs(answers []briefdomain.BriefAnswer) []string {
+ ids := make([]string, 0, len(answers))
+ for _, answer := range answers {
+ ids = append(ids, answer.TargetQuestionID)
+ }
+ return ids
+}
+
+func questionCoverage(results []caseResult) map[string]int {
+ coverage := map[string]int{"cases": len(results)}
+ for _, result := range results {
+ for _, question := range result.QuestionTemplate {
+ coverage[question.ID]++
+ }
+ }
+ return coverage
+}
+
+func briefCoverage(results []caseResult) map[string]int {
+ coverage := map[string]int{"cases": len(results)}
+ for _, result := range results {
+ if allChecksPassed(result.BriefChecks) {
+ coverage["passing_briefs"]++
+ }
+ if result.DeepDiveCount == briefdomain.MaxTotalFollowUps {
+ coverage["max_deep_dive_briefs"]++
+ }
+ }
+ return coverage
+}
+
+func casesMarkdown(report scenarioReport) string {
+ var builder strings.Builder
+ builder.WriteString("# Interview template scenario\n\n")
+ builder.WriteString("- Generated by: `cmd/scenario/interview_template`\n")
+ builder.WriteString(fmt.Sprintf("- Offline only: `%v`\n", report.OfflineOnly))
+ builder.WriteString(fmt.Sprintf("- Cases: `%d`\n\n", len(report.Cases)))
+ builder.WriteString("| Case | Persona | Format | Questions | Custom answers | Deep dives | Checks | Brief |\n")
+ builder.WriteString("|---|---|---|---:|---:|---:|---|---|\n")
+ for _, result := range report.Cases {
+ builder.WriteString(fmt.Sprintf("| `%s` | `%s` | `%s` | %d | %d | %d | %s | `%s` |\n",
+ result.ID,
+ result.PersonaID,
+ result.OutputFormatID,
+ result.QuestionCount,
+ len(result.CustomAnswerIDs),
+ result.DeepDiveCount,
+ checkStatus(result),
+ result.BriefPath,
+ ))
+ }
+ return builder.String()
+}
+
+func checkStatus(result caseResult) string {
+ if allChecksPassed(result.TemplateChecks) && allChecksPassed(result.BriefChecks) {
+ return "passed"
+ }
+ return "failed"
+}
+
+func allChecksPassed(checks []checkResult) bool {
+ for _, item := range checks {
+ if !item.Passed {
+ return false
+ }
+ }
+ return true
+}
+
+func check(name string, passed bool, detail string) checkResult {
+ return checkResult{Name: name, Passed: passed, Detail: detail}
+}
+
+func uniqueQuestionIDs(questions []briefdomain.ArticleQuestion) bool {
+ seen := map[string]bool{}
+ for _, question := range questions {
+ if seen[question.ID] {
+ return false
+ }
+ seen[question.ID] = true
+ }
+ return true
+}
+
+func containsAllQuestionIDs(questions []briefdomain.ArticleQuestion, ids []string) bool {
+ for _, id := range ids {
+ if !containsQuestionID(questions, id) {
+ return false
+ }
+ }
+ return true
+}
+
+func containsQuestionID(questions []briefdomain.ArticleQuestion, id string) bool {
+ for _, question := range questions {
+ if question.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
+func writeJSON(path string, value any) error {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode %s: %w", path, err)
+ }
+ return writeFile(path, string(encoded)+"\n")
+}
+
+func writeFile(path, content string) error {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", path, err)
+ }
+ return nil
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/cmd/scenario/interview_template/main_test.go b/cmd/scenario/interview_template/main_test.go
new file mode 100644
index 0000000..526c9ff
--- /dev/null
+++ b/cmd/scenario/interview_template/main_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ 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 TestRunScenarioCoversAllPersonaFormatTemplates(t *testing.T) {
+ outputDir := t.TempDir()
+ report, err := runScenario(context.Background(), outputDir)
+ if err != nil {
+ t.Fatalf("run scenario: %v", err)
+ }
+
+ wantCases := len(personadomain.DefaultRegistry().List()) * len(outputformat.DefaultRegistry().List())
+ if len(report.Cases) != wantCases {
+ t.Fatalf("cases = %d, want %d", len(report.Cases), wantCases)
+ }
+ if !report.OfflineOnly {
+ t.Fatal("scenario must remain offline-only")
+ }
+ for _, result := range report.Cases {
+ if result.QuestionCount < len(briefdomain.FixedQuestions()) {
+ t.Fatalf("%s question count = %d", result.ID, result.QuestionCount)
+ }
+ if !allChecksPassed(result.TemplateChecks) {
+ t.Fatalf("%s template checks failed: %#v", result.ID, result.TemplateChecks)
+ }
+ if !allChecksPassed(result.BriefChecks) {
+ t.Fatalf("%s brief checks failed: %#v", result.ID, result.BriefChecks)
+ }
+ if result.DeepDiveCount != briefdomain.MaxTotalFollowUps {
+ t.Fatalf("%s deep dives = %d, want %d", result.ID, result.DeepDiveCount, briefdomain.MaxTotalFollowUps)
+ }
+ if _, err := os.Stat(result.BriefPath); err != nil {
+ t.Fatalf("%s brief artifact: %v", result.ID, err)
+ }
+ if _, err := os.Stat(result.SessionPath); err != nil {
+ t.Fatalf("%s session artifact: %v", result.ID, err)
+ }
+ }
+}
+
+func TestScenarioWritesSimulatedArticleBriefs(t *testing.T) {
+ outputDir := t.TempDir()
+ report, err := runScenario(context.Background(), outputDir)
+ if err != nil {
+ t.Fatalf("run scenario: %v", err)
+ }
+
+ target := findCase(t, report.Cases, personadomain.IDCloudia+"_"+outputformat.IDQiitaArticle)
+ encoded, err := os.ReadFile(target.BriefPath)
+ if err != nil {
+ t.Fatalf("read brief: %v", err)
+ }
+ var brief briefdomain.ArticleBrief
+ if err := json.Unmarshal(encoded, &brief); err != nil {
+ t.Fatalf("decode brief: %v", err)
+ }
+ if brief.PersonaID != personadomain.IDCloudia {
+ t.Fatalf("persona = %q", brief.PersonaID)
+ }
+ if brief.OutputFormatID != outputformat.IDQiitaArticle {
+ t.Fatalf("format = %q", brief.OutputFormatID)
+ }
+ assertAnswerPresent(t, brief.CustomAnswers, briefdomain.QuestionIDTargetStack)
+ assertAnswerPresent(t, brief.CustomAnswers, briefdomain.QuestionIDCodeExamples)
+ assertAnswerPresent(t, brief.CustomAnswers, briefdomain.QuestionIDCloudiaViewpoint)
+ if len(brief.DeepDives) != briefdomain.MaxTotalFollowUps {
+ t.Fatalf("deep dives = %d, want %d", len(brief.DeepDives), briefdomain.MaxTotalFollowUps)
+ }
+ if _, err := os.Stat(filepath.Join(outputDir, "report.json")); err != nil {
+ t.Fatalf("report artifact: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(outputDir, "cases.md")); err != nil {
+ t.Fatalf("cases markdown artifact: %v", err)
+ }
+}
+
+func findCase(t *testing.T, cases []caseResult, id string) caseResult {
+ t.Helper()
+ for _, item := range cases {
+ if item.ID == id {
+ return item
+ }
+ }
+ t.Fatalf("case %s not found", id)
+ return caseResult{}
+}
+
+func assertAnswerPresent(t *testing.T, answers []briefdomain.BriefAnswer, questionID string) {
+ t.Helper()
+ for _, answer := range answers {
+ if answer.QuestionID == questionID && answer.Content != "" {
+ return
+ }
+ }
+ t.Fatalf("answer %s not found in %#v", questionID, answers)
+}
diff --git a/cmd/scenario/live_media_matrix/main.go b/cmd/scenario/live_media_matrix/main.go
index a5c5042..967ec06 100644
--- a/cmd/scenario/live_media_matrix/main.go
+++ b/cmd/scenario/live_media_matrix/main.go
@@ -23,16 +23,17 @@ type matrixOutput struct {
}
type matrixCase struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- OutputFormatID string `json:"output_format_id"`
- Medium string `json:"medium"`
- Style string `json:"style"`
- Theme string `json:"theme"`
- TargetLengthStructure string `json:"target_length_structure"`
- SourceSelectors []string `json:"source_selectors"`
- BriefPath string `json:"brief_path"`
- PromptPath string `json:"prompt_path"`
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ Theme string `json:"theme"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ SourceSelectors []string `json:"source_selectors"`
+ BriefPath string `json:"brief_path"`
+ PromptPath string `json:"prompt_path"`
+ ActiveGates scenarioGates `json:"active_gates"`
}
type aggregateReport struct {
@@ -44,32 +45,44 @@ type aggregateReport struct {
}
type resultRow struct {
- CaseID string `json:"case_id"`
- Medium string `json:"medium"`
- Style string `json:"style"`
- PersonaID string `json:"persona_id"`
- OutputFormatID string `json:"output_format_id"`
- Theme string `json:"theme"`
- TargetLengthStructure string `json:"target_length_structure"`
- SourceSelectors []string `json:"source_selectors"`
- Status string `json:"status"`
- ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
- FirstChunkMS int `json:"first_chunk_ms,omitempty"`
- Chunks int `json:"chunks,omitempty"`
- Score float64 `json:"score,omitempty"`
- Runes int `json:"runes,omitempty"`
- Passed bool `json:"passed,omitempty"`
- ScenarioPassed bool `json:"scenario_passed,omitempty"`
- VerificationPerformed bool `json:"verification_performed,omitempty"`
- VerificationPassed bool `json:"verification_passed,omitempty"`
- LLMBaseURL string `json:"llm_base_url,omitempty"`
- LLMModel string `json:"llm_model,omitempty"`
- VerifyModel string `json:"verify_model,omitempty"`
- OutputDir string `json:"output_dir"`
- DraftPath string `json:"draft_path,omitempty"`
- EvaluationPath string `json:"evaluation_path,omitempty"`
- VerificationPath string `json:"verification_path,omitempty"`
- Error string `json:"error,omitempty"`
+ CaseID string `json:"case_id"`
+ Medium string `json:"medium"`
+ Style string `json:"style"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ Theme string `json:"theme"`
+ TargetLengthStructure string `json:"target_length_structure"`
+ SourceSelectors []string `json:"source_selectors"`
+ Status string `json:"status"`
+ ActiveGates scenarioGates `json:"active_gates"`
+ ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
+ FirstChunkMS int `json:"first_chunk_ms,omitempty"`
+ Chunks int `json:"chunks,omitempty"`
+ Score float64 `json:"score,omitempty"`
+ MinStyleScore float64 `json:"min_style_score,omitempty"`
+ Runes int `json:"runes,omitempty"`
+ MinRunes int `json:"min_runes,omitempty"`
+ Passed bool `json:"passed,omitempty"`
+ ScenarioPassed bool `json:"scenario_passed,omitempty"`
+ VerificationPerformed bool `json:"verification_performed,omitempty"`
+ VerificationPassed bool `json:"verification_passed,omitempty"`
+ LLMBaseURL string `json:"llm_base_url,omitempty"`
+ LLMModel string `json:"llm_model,omitempty"`
+ VerifyModel string `json:"verify_model,omitempty"`
+ OutputDir string `json:"output_dir"`
+ DraftPath string `json:"draft_path,omitempty"`
+ EvaluationPath string `json:"evaluation_path,omitempty"`
+ VerificationPath string `json:"verification_path,omitempty"`
+ FailurePath string `json:"failure_path,omitempty"`
+ RawOutputPaths []string `json:"raw_output_paths,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type scenarioGates struct {
+ MinRunes int `json:"min_runes"`
+ MinStyleScore float64 `json:"min_style_score"`
+ StructuralGateLabels []string `json:"structural_gate_labels"`
+ StructuralSignals []string `json:"structural_signals"`
}
func main() {
@@ -144,6 +157,9 @@ func plannedRow(item matrixCase, outputDir string) resultRow {
TargetLengthStructure: item.TargetLengthStructure,
SourceSelectors: append([]string(nil), item.SourceSelectors...),
Status: "planned",
+ ActiveGates: item.ActiveGates,
+ MinStyleScore: item.ActiveGates.MinStyleScore,
+ MinRunes: item.ActiveGates.MinRunes,
OutputDir: outputDir,
}
}
@@ -156,6 +172,12 @@ func runCase(item matrixCase, outputDir string) resultRow {
row.DraftPath = filepath.Join(outputDir, "draft.md")
row.EvaluationPath = filepath.Join(outputDir, "evaluation.json")
row.VerificationPath = filepath.Join(outputDir, "verification.json")
+ applyRunMetrics(&row, readKeyValuesFile(filepath.Join(outputDir, "stdout.txt")), item.ActiveGates)
+ return row
+ }
+ if err := os.RemoveAll(outputDir); err != nil {
+ row.Status = "failed"
+ row.Error = err.Error()
return row
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
@@ -165,11 +187,7 @@ func runCase(item matrixCase, outputDir string) resultRow {
}
cmd := exec.Command("go", "run", "./cmd/scenario/draft_generation")
- cmd.Env = append(os.Environ(),
- "RUN_LOCAL_LLM_SCENARIO=1",
- "ARTICLE_BRIEF_PATH="+item.BriefPath,
- "SCENARIO_OUTPUT_DIR="+outputDir,
- )
+ cmd.Env = draftGenerationEnv(item, outputDir)
if os.Getenv("SCENARIO_STREAM_DRAFT") == "" {
cmd.Env = append(cmd.Env, "SCENARIO_STREAM_DRAFT=1")
}
@@ -180,12 +198,31 @@ func runCase(item matrixCase, outputDir string) resultRow {
writeFile(filepath.Join(outputDir, "stdout.txt"), stdout.String())
writeFile(filepath.Join(outputDir, "stderr.txt"), stderr.String())
- values := parseKeyValues(stdout.String())
+ applyRunMetrics(&row, parseKeyValues(stdout.String()), item.ActiveGates)
+ if err != nil {
+ row.Status = "failed"
+ row.Error = strings.TrimSpace(stderr.String())
+ if row.Error == "" {
+ row.Error = err.Error()
+ }
+ applyFailureArtifacts(&row, outputDir)
+ return row
+ }
+ row.Status = "passed"
+ return row
+}
+
+func applyRunMetrics(row *resultRow, values map[string]string, gates scenarioGates) {
+ if len(values) == 0 {
+ return
+ }
row.ElapsedSeconds = floatValue(values["elapsed_seconds"])
row.FirstChunkMS = intValue(values["first_chunk_ms"])
row.Chunks = intValue(values["chunks"])
row.Score = floatValue(values["score"])
+ row.MinStyleScore = floatValueOrDefault(values["min_style_score"], gates.MinStyleScore)
row.Runes = intValue(values["runes"])
+ row.MinRunes = intValueOrDefault(values["min_draft_runes"], gates.MinRunes)
row.Passed = boolValue(values["passed"])
row.ScenarioPassed = boolValue(values["scenario_passed"])
row.VerificationPerformed = boolValue(values["verification_performed"])
@@ -193,19 +230,89 @@ func runCase(item matrixCase, outputDir string) resultRow {
row.LLMBaseURL = values["llm_base_url"]
row.LLMModel = values["llm_model"]
row.VerifyModel = values["verify_model"]
- row.DraftPath = values["draft"]
- row.EvaluationPath = values["evaluation"]
- row.VerificationPath = values["verification"]
+ row.DraftPath = valueOrDefault(values["draft"], row.DraftPath)
+ row.EvaluationPath = valueOrDefault(values["evaluation"], row.EvaluationPath)
+ row.VerificationPath = valueOrDefault(values["verification"], row.VerificationPath)
+}
+
+func draftGenerationEnv(item matrixCase, outputDir string) []string {
+ env := append(os.Environ(),
+ "RUN_LOCAL_LLM_SCENARIO=1",
+ "ARTICLE_BRIEF_PATH="+item.BriefPath,
+ "SCENARIO_OUTPUT_DIR="+outputDir,
+ )
+ if item.ActiveGates.MinStyleScore > 0 {
+ env = append(env, fmt.Sprintf("SCENARIO_MIN_STYLE_SCORE=%.1f", item.ActiveGates.MinStyleScore))
+ }
+ if item.ActiveGates.MinRunes > 0 {
+ env = append(env, fmt.Sprintf("SCENARIO_MIN_DRAFT_RUNES=%d", item.ActiveGates.MinRunes))
+ }
+ return env
+}
+
+type failureAttemptReport struct {
+ RuntimeMetrics attemptRuntimeMetrics `json:"runtime_metrics"`
+ Context failureContext `json:"context"`
+ RawOutputs []rawAttemptArtifact `json:"raw_outputs"`
+}
+
+type attemptRuntimeMetrics struct {
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ FirstChunkMs int `json:"first_chunk_ms,omitempty"`
+ Chunks int `json:"chunks,omitempty"`
+}
+
+type failureContext struct {
+ LLMBaseURL string `json:"llm_base_url"`
+ LLMModel string `json:"llm_model"`
+ VerifyModel string `json:"verify_model"`
+ OutputFormatID string `json:"output_format_id"`
+}
+
+type rawAttemptArtifact struct {
+ Path string `json:"path"`
+}
+
+func applyFailureArtifacts(row *resultRow, outputDir string) {
+ path := latestFailureAttemptPath(outputDir)
+ if path == "" {
+ return
+ }
+ encoded, err := os.ReadFile(path)
if err != nil {
- row.Status = "failed"
- row.Error = strings.TrimSpace(stderr.String())
- if row.Error == "" {
- row.Error = err.Error()
+ return
+ }
+ var report failureAttemptReport
+ if err := json.Unmarshal(encoded, &report); err != nil {
+ return
+ }
+ row.FailurePath = path
+ if row.ElapsedSeconds == 0 {
+ row.ElapsedSeconds = report.RuntimeMetrics.ElapsedSeconds
+ }
+ if row.FirstChunkMS == 0 {
+ row.FirstChunkMS = report.RuntimeMetrics.FirstChunkMs
+ }
+ if row.Chunks == 0 {
+ row.Chunks = report.RuntimeMetrics.Chunks
+ }
+ row.LLMBaseURL = valueOrDefault(row.LLMBaseURL, report.Context.LLMBaseURL)
+ row.LLMModel = valueOrDefault(row.LLMModel, report.Context.LLMModel)
+ row.VerifyModel = valueOrDefault(row.VerifyModel, report.Context.VerifyModel)
+ for _, raw := range report.RawOutputs {
+ if strings.TrimSpace(raw.Path) != "" {
+ row.RawOutputPaths = append(row.RawOutputPaths, raw.Path)
}
- return row
}
- row.Status = "passed"
- return row
+}
+
+func latestFailureAttemptPath(outputDir string) string {
+ matches, err := filepath.Glob(filepath.Join(outputDir, "failure_attempt_*.json"))
+ if err != nil || len(matches) == 0 {
+ return ""
+ }
+ sort.Strings(matches)
+ return matches[len(matches)-1]
}
func parseKeyValues(output string) map[string]string {
@@ -220,23 +327,34 @@ func parseKeyValues(output string) map[string]string {
return values
}
+func readKeyValuesFile(path string) map[string]string {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ return parseKeyValues(string(content))
+}
+
func markdownReport(report aggregateReport) string {
var builder strings.Builder
builder.WriteString("# Live media matrix aggregate\n\n")
builder.WriteString(fmt.Sprintf("- Generated at: `%s`\n", report.GeneratedAt))
builder.WriteString(fmt.Sprintf("- Live mode: `%v`\n", report.Live))
builder.WriteString(fmt.Sprintf("- Matrix: `%s`\n\n", report.MatrixPath))
- builder.WriteString("| Case | Medium | Style | Status | Seconds | Score | Runes | Verification | Output |\n")
- builder.WriteString("|---|---|---|---|---:|---:|---:|---|---|\n")
+ builder.WriteString("| Case | Medium | Style | Status | Gates | Seconds | Score | Runes | Verification | Output |\n")
+ builder.WriteString("|---|---|---|---|---|---:|---:|---:|---|---|\n")
for _, row := range report.Rows {
- builder.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %.2f | %.1f | %d | %v | `%s` |\n",
+ builder.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s | %.2f | %.1f / %.1f | %d / %d | %v | `%s` |\n",
row.CaseID,
escapePipes(row.Medium),
escapePipes(row.Style),
row.Status,
+ escapePipes(gateSummary(row.ActiveGates)),
row.ElapsedSeconds,
row.Score,
+ row.MinStyleScore,
row.Runes,
+ row.MinRunes,
row.VerificationPassed,
row.OutputDir,
))
@@ -251,6 +369,18 @@ func markdownReport(report aggregateReport) string {
return builder.String()
}
+func gateSummary(gates scenarioGates) string {
+ if gates.MinRunes == 0 && gates.MinStyleScore == 0 && len(gates.StructuralGateLabels) == 0 {
+ return ""
+ }
+ return fmt.Sprintf(
+ "min %.1f style / %d runes; %s",
+ gates.MinStyleScore,
+ gates.MinRunes,
+ strings.Join(gates.StructuralGateLabels, ", "),
+ )
+}
+
func failedRows(rows []resultRow) []resultRow {
failures := make([]resultRow, 0)
for _, row := range rows {
@@ -302,11 +432,34 @@ func intValue(value string) int {
return parsed
}
+func intValueOrDefault(value string, fallback int) int {
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
+
func floatValue(value string) float64 {
parsed, _ := strconv.ParseFloat(value, 64)
return parsed
}
+func floatValueOrDefault(value string, fallback float64) float64 {
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
+
+func valueOrDefault(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
func envOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
diff --git a/cmd/scenario/live_media_matrix/main_test.go b/cmd/scenario/live_media_matrix/main_test.go
new file mode 100644
index 0000000..9d39473
--- /dev/null
+++ b/cmd/scenario/live_media_matrix/main_test.go
@@ -0,0 +1,170 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestPlannedRowReportsActiveGates(t *testing.T) {
+ item := matrixCase{
+ ID: "cor_homepage_section",
+ Medium: "homepage",
+ Style: "concise product section",
+ PersonaID: "terisuke",
+ OutputFormatID: "homepage_section",
+ ActiveGates: scenarioGates{
+ MinRunes: 350,
+ MinStyleScore: 72,
+ StructuralGateLabels: []string{"homepage_short_html", "section_element", "cta"},
+ StructuralSignals: []string{"", "= 1000 {
+ t.Fatalf("homepage minimum runes should stay short HTML focused, got %d", homepageGates.MinRunes)
+ }
+ if !contains(homepageGates.StructuralGateLabels, "homepage_short_html") {
+ t.Fatalf("homepage gates missing homepage_short_html label: %v", homepageGates.StructuralGateLabels)
+ }
+ for _, signal := range []string{"= 82.0, runes >= 2800, note long-form structure | `note:cor_instrument` |
+| `cor_blog_technical_report` | Cor.inc company blog | technical report | style >= 80.0, runes >= 2200, frontmatter/report/verification structure | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cor_blog_vision_sharing` | Cor.inc company blog | vision sharing | style >= 80.0, runes >= 1600, frontmatter/company policy structure | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
+| `cloudia_zenn_tutorial` | Zenn | tutorial | style >= 82.0, runes >= 1800, Zenn frontmatter/topics/message/code structure | `zenn:cloudia` |
+| `cloudia_qiita_how_to` | Qiita | practical how-to | style >= 82.0, runes >= 1400, Qiita frontmatter/note/diff/repro structure | `qiita:Cloudia_Cor_Inc` |
+| `cor_homepage_section` | homepage | concise product section | style >= 72.0, runes >= 350, short HTML section/h2/p/CTA structure | `rss:https://cor-jp.com/rss.xml`, `github:Cor-Incorporated/corsweb2024/src/content/blog/ja` |
## Optional live source phase
@@ -112,6 +113,8 @@ Expected planned-mode artifacts:
- `tmp/media_matrix/live/aggregate.json`
- `tmp/media_matrix/live/aggregate.md`
+Planned and live aggregate rows report the active gate object beside status, actual score, and actual runes. In live mode, those gates are passed to `cmd/scenario/draft_generation` as `SCENARIO_MIN_STYLE_SCORE` and `SCENARIO_MIN_DRAFT_RUNES`.
+
To execute all cases against the configured Evo X2 Tailnet OpenAI-compatible API, use:
```sh
@@ -137,19 +140,37 @@ Use the `planned_llm_command` entries in `tmp/media_matrix/matrix.json` or `tmp/
Compare results across phases and cases with this schema:
-| Case | Phase | Medium | Style | Target length | Elapsed seconds | Score | Verification passed | Runes | Output |
+| Case | Phase | Medium | Style | Active gates | Elapsed seconds | Score / min | Verification passed | Runes / min | Output |
|---|---|---|---|---|---:|---:|---|---:|---|
-| `terisuke_note_essay` | offline matrix | note | reflective essay | 3000字前後 | | | prompt checks only | | `tmp/media_matrix/prompts/terisuke_note_essay.prompt.md` |
-| `terisuke_note_essay` | live draft | note | reflective essay | 3000字前後 | | | | | |
-| `cor_blog_technical_report` | live draft | company blog | technical report | 2200-2800字 | | | | | |
-| `cor_blog_vision_sharing` | live draft | company blog | vision sharing | 1600-2200字 | | | | | |
-| `cloudia_zenn_tutorial` | live draft | Zenn | tutorial | 1800-2400字 | | | | | |
-| `cloudia_qiita_how_to` | live draft | Qiita | practical how-to | 1400-2000字 | | | | | |
-| `cor_homepage_section` | live draft | homepage | concise product section | 400-700字 | | | | | |
+| `terisuke_note_essay` | offline matrix | note | reflective essay | 82.0 / 2800 / note long-form | | | prompt checks only | | `tmp/media_matrix/prompts/terisuke_note_essay.prompt.md` |
+| `terisuke_note_essay` | live draft | note | reflective essay | 82.0 / 2800 / note long-form | | | | | |
+| `cor_blog_technical_report` | live draft | company blog | technical report | 80.0 / 2200 / Cor report | | | | | |
+| `cor_blog_vision_sharing` | live draft | company blog | vision sharing | 80.0 / 1600 / Cor vision | | | | | |
+| `cloudia_zenn_tutorial` | live draft | Zenn | tutorial | 82.0 / 1800 / Zenn tutorial | | | | | |
+| `cloudia_qiita_how_to` | live draft | Qiita | practical how-to | 82.0 / 1400 / Qiita how-to | | | | | |
+| `cor_homepage_section` | live draft | homepage | concise product section | 72.0 / 350 / short HTML section CTA | | | | | |
Acceptance criteria for the integrated evaluation:
- Offline matrix passes before any live work.
- Live source phase confirms each selector still returns usable material, with GitHub Markdown preferred over RSS for full Cor.inc blog body text.
-- Each live draft records `elapsed_seconds`, style `score`, `passed`, `verification_performed`, `verification_passed`, and `runes` from `cmd/scenario/draft_generation`.
+- Each live draft records `elapsed_seconds`, style `score`, `min_style_score`, `passed`, `verification_performed`, `verification_passed`, `runes`, `min_draft_runes`, and `active_gates` from `cmd/scenario/draft_generation` and the media matrix.
+- Homepage acceptance is based on short HTML section signals (`section`, `h2`, paragraph, CTA, concise copy), not long-form article length.
+- Long-form note, Cor blog, Zenn, and Qiita acceptance stays strict through their per-case style and rune minimums.
- Failures are grouped by dimension: source selector, persona, medium/output format, style, target length, or verifier result.
+
+## 2026-05-03 staged Zenn live slice
+
+After #70-#73 implementation, the first staged live rerun used the previously failing `cloudia_zenn_tutorial` case:
+
+```sh
+LIVE_MEDIA_MATRIX_CASES=cloudia_zenn_tutorial make scenario-media-matrix-live
+```
+
+Final result:
+
+| Case | Status | Seconds | Score / min | Runes / min | Verification | Failure class |
+|---|---|---:|---:|---:|---|---|
+| `cloudia_zenn_tutorial` | failed | `702.17` | `73.6 / 82.0` | `3905 / 1800` | passed | strict style score |
+
+This is not a #40 acceptance pass yet, but it confirms the pipeline now progresses beyond the prior format-validation failure. The remaining failure is style calibration for Cloudia/Zenn, not Tailnet transport or Zenn syntax.
diff --git a/internal/application/draft/prompt.go b/internal/application/draft/prompt.go
index 1345d40..2679886 100644
--- a/internal/application/draft/prompt.go
+++ b/internal/application/draft/prompt.go
@@ -115,6 +115,41 @@ func BuildStyleRevisionPrompt(originalPrompt, draftMarkdown string, evaluation S
return prompt.String()
}
+// BuildFormatRepairPrompt asks for one bounded rewrite when the draft uses the wrong platform syntax.
+func BuildFormatRepairPrompt(format outputformat.OutputFormat, rawOutput string, validationErr error) string {
+ var prompt strings.Builder
+ prompt.WriteString("以下の出力は記事本文として使えません。媒体形式だけを修正し、内容の意図は維持してください。\n")
+ prompt.WriteString("前置き、解説、内部メモ、コードフェンスでの囲みは出力しないでください。修正版の記事本文だけを返してください。\n\n")
+
+ prompt.WriteString("## Validator error\n")
+ if validationErr == nil {
+ prompt.WriteString("- unknown validation error\n\n")
+ } else {
+ prompt.WriteString("- " + validationErr.Error() + "\n\n")
+ }
+
+ prompt.WriteString("## Output format rules\n")
+ appendLine(&prompt, "OutputFormat", format.ID+" / "+format.DisplayName)
+ appendLine(&prompt, "媒体ルール", format.PromptFragment)
+ prompt.WriteString("\n")
+
+ if guideMarkdown := formatGuideMarkdown(format.ID); guideMarkdown != "" {
+ prompt.WriteString("## 媒体別Markdownガイド\n")
+ prompt.WriteString(guideMarkdown)
+ prompt.WriteString("\n\n")
+ }
+
+ prompt.WriteString("## 出力条件\n")
+ appendOutputConditions(&prompt, format.ID)
+ prompt.WriteString("修正対象以外の媒体記法を混ぜないでください。\n")
+ prompt.WriteString("必ず修正版の記事本文だけを出力してください。\n\n")
+
+ prompt.WriteString("## Raw model output\n")
+ prompt.WriteString(truncateRunes(rawOutput, 7000))
+ prompt.WriteString("\n")
+ return prompt.String()
+}
+
// BuildSectionRegenerationPrompt asks for one replacement section only.
func BuildSectionRegenerationPrompt(guide WritingStyleGuide, brief ArticleBrief, profile AuthorStyleProfile, persona personadomain.Persona, format outputformat.OutputFormat, draftMarkdown string, section MarkdownSection) string {
var prompt strings.Builder
diff --git a/internal/application/draft/service.go b/internal/application/draft/service.go
index 6056894..5774b52 100644
--- a/internal/application/draft/service.go
+++ b/internal/application/draft/service.go
@@ -48,6 +48,27 @@ type Service struct {
verifier DraftVerifier
}
+// UnusableDraftError reports a validation failure while preserving every raw generation attempt.
+type UnusableDraftError struct {
+ FormatID string
+ Attempts []GenerationAttempt
+ Err error
+}
+
+func (e *UnusableDraftError) Error() string {
+ if e == nil || e.Err == nil {
+ return "local llm returned an unusable draft"
+ }
+ return "local llm returned an unusable draft: " + e.Err.Error()
+}
+
+func (e *UnusableDraftError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Err
+}
+
// NewService creates a draft generation service.
func NewService(generator TextGenerator) *Service {
return &Service{generator: generator}
@@ -91,6 +112,7 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
}
prompt := BuildPromptForModeWithProfile(req.StyleGuide, req.Brief, req.AuthorProfile, persona, format)
+ attempts := make([]GenerationAttempt, 0, 2)
if err := emitStatus(events, "draft_generation_started"); err != nil {
return GenerateResult{}, err
}
@@ -102,15 +124,34 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
return GenerateResult{}, err
}
articleDraft, err := articledomain.NewDraftForFormat(rawDraft, format.ID)
+ attempts = appendGenerationAttempt(attempts, "initial", rawDraft, err)
if err != nil {
- return GenerateResult{}, fmt.Errorf("local llm returned an unusable draft: %w", err)
+ if !isRecoverableFormatValidationError(format.ID, err) {
+ return GenerateResult{}, &UnusableDraftError{FormatID: format.ID, Attempts: attempts, Err: err}
+ }
+ if err := emitStatus(events, "draft_format_repair_started"); err != nil {
+ return GenerateResult{}, err
+ }
+ repairedRaw, repairErr := s.generator.Generate(ctx, BuildFormatRepairPrompt(format, rawDraft, err))
+ if repairErr != nil {
+ return GenerateResult{}, fmt.Errorf("repair draft format with local llm: %w", repairErr)
+ }
+ articleDraft, repairErr = articledomain.NewDraftForFormat(repairedRaw, format.ID)
+ attempts = appendGenerationAttempt(attempts, "format_repair", repairedRaw, repairErr)
+ if repairErr != nil {
+ return GenerateResult{}, &UnusableDraftError{FormatID: format.ID, Attempts: attempts, Err: repairErr}
+ }
}
evaluation := EvaluateStyle(req.AuthorProfile, req.Brief, articleDraft)
if shouldReviseForStrictStyle(evaluation) {
if err := emitStatus(events, "style_revision_started"); err != nil {
return GenerateResult{}, err
}
- revisedDraft, revisedEvaluation, ok := s.reviseOnce(ctx, prompt, articleDraft, evaluation, format.ID, req)
+ revisedDraft, revisedEvaluation, revisionAttempt, ok := s.reviseOnce(ctx, prompt, articleDraft, evaluation, format.ID, req)
+ if revisionAttempt.RawOutput != "" || revisionAttempt.ValidationError != "" {
+ revisionAttempt.Index = len(attempts) + 1
+ attempts = append(attempts, revisionAttempt)
+ }
if ok {
articleDraft = revisedDraft
evaluation = revisedEvaluation
@@ -130,6 +171,7 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
Draft: articleDraft,
Evaluation: evaluation,
Verification: verification,
+ Attempts: attempts,
}, nil
}
@@ -163,21 +205,22 @@ func (s *Service) generateRaw(ctx context.Context, prompt string, onChunk func(s
return s.generator.Generate(ctx, prompt)
}
-func (s *Service) reviseOnce(ctx context.Context, originalPrompt string, articleDraft articledomain.Draft, evaluation StyleEvaluation, formatID string, req GenerateRequest) (articledomain.Draft, StyleEvaluation, bool) {
+func (s *Service) reviseOnce(ctx context.Context, originalPrompt string, articleDraft articledomain.Draft, evaluation StyleEvaluation, formatID string, req GenerateRequest) (articledomain.Draft, StyleEvaluation, GenerationAttempt, bool) {
revisionPrompt := BuildStyleRevisionPrompt(originalPrompt, articleDraft.Markdown(), evaluation)
rawDraft, err := s.generator.Generate(ctx, revisionPrompt)
if err != nil {
- return articledomain.Draft{}, StyleEvaluation{}, false
+ return articledomain.Draft{}, StyleEvaluation{}, GenerationAttempt{}, false
}
revisedDraft, err := articledomain.NewDraftForFormat(rawDraft, formatID)
if err != nil {
- return articledomain.Draft{}, StyleEvaluation{}, false
+ return articledomain.Draft{}, StyleEvaluation{}, generationAttempt("style_revision", rawDraft, err), false
}
+ attempt := generationAttempt("style_revision", rawDraft, nil)
revisedEvaluation := EvaluateStyle(req.AuthorProfile, req.Brief, revisedDraft)
if revisedEvaluation.Passed || len(revisedEvaluation.Failures) <= len(evaluation.Failures) || revisedEvaluation.Comparison.Score >= evaluation.Comparison.Score {
- return revisedDraft, revisedEvaluation, true
+ return revisedDraft, revisedEvaluation, attempt, true
}
- return articledomain.Draft{}, StyleEvaluation{}, false
+ return articledomain.Draft{}, StyleEvaluation{}, attempt, false
}
func emitStatus(events StreamEvents, status string) error {
@@ -199,6 +242,41 @@ func shouldReviseForStrictStyle(evaluation StyleEvaluation) bool {
return false
}
+func appendGenerationAttempt(attempts []GenerationAttempt, kind, raw string, validationErr error) []GenerationAttempt {
+ attempt := generationAttempt(kind, raw, validationErr)
+ attempt.Index = len(attempts) + 1
+ return append(attempts, attempt)
+}
+
+func generationAttempt(kind, raw string, validationErr error) GenerationAttempt {
+ attempt := GenerationAttempt{
+ Kind: kind,
+ RawOutput: raw,
+ }
+ if validationErr != nil {
+ attempt.ValidationError = validationErr.Error()
+ }
+ return attempt
+}
+
+func isRecoverableFormatValidationError(formatID string, err error) bool {
+ if err == nil {
+ return false
+ }
+ message := err.Error()
+ if strings.Contains(message, "preamble before the article") {
+ return true
+ }
+ switch formatID {
+ case outputformat.IDZennArticle:
+ return strings.Contains(message, "Qiita :::note")
+ case outputformat.IDQiitaArticle:
+ return strings.Contains(message, "Zenn-specific notation")
+ default:
+ return false
+ }
+}
+
func validateRequest(req GenerateRequest) error {
if err := req.StyleGuide.Validate(); err != nil {
return fmt.Errorf("writing style guide is invalid: %w", err)
diff --git a/internal/application/draft/service_test.go b/internal/application/draft/service_test.go
index 083f971..fd15d4b 100644
--- a/internal/application/draft/service_test.go
+++ b/internal/application/draft/service_test.go
@@ -2,6 +2,7 @@ package draft
import (
"context"
+ "errors"
"strings"
"testing"
"time"
@@ -225,6 +226,106 @@ func TestGenerateUsesPersonaAndOutputFormat(t *testing.T) {
}
}
+func TestGenerateRunsOneFormatRepairRetry(t *testing.T) {
+ invalidZenn := "---\n" +
+ "title: \"Goで検証する\"\n" +
+ "emoji: \"🧪\"\n" +
+ "type: \"tech\"\n" +
+ "topics: [\"go\", \"test\"]\n" +
+ "published: false\n" +
+ "---\n\n" +
+ "## 実装\n\n" +
+ ":::note info\nQiitaの補足です\n:::\n"
+ repairedZenn := strings.ReplaceAll(invalidZenn, ":::note info", ":::message")
+ generator := &sequenceGenerator{drafts: []string{invalidZenn, repairedZenn}}
+ profile, styleGuide := profileAndGuideFromDraft(t, repairedZenn)
+ persona, _ := personadomain.DefaultRegistry().Get(personadomain.IDCloudia)
+ format, _ := outputformat.DefaultRegistry().Get(outputformat.IDZennArticle)
+
+ result, err := NewService(generator).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{
+ StyleProfileID: profile.ID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Theme: "Goで検証する",
+ },
+ AuthorProfile: profile,
+ Persona: persona,
+ OutputFormat: format,
+ })
+ if err != nil {
+ t.Fatalf("generate with format repair: %v", err)
+ }
+ if generator.calls != 2 {
+ t.Fatalf("calls = %d, want 2", generator.calls)
+ }
+ if result.Draft.Markdown() != strings.TrimSpace(repairedZenn) {
+ t.Fatalf("unexpected repaired draft:\n%s", result.Draft.Markdown())
+ }
+ if len(result.Attempts) != 2 {
+ t.Fatalf("attempts = %#v, want 2 attempts", result.Attempts)
+ }
+ if result.Attempts[0].Kind != "initial" || result.Attempts[0].RawOutput != invalidZenn || !strings.Contains(result.Attempts[0].ValidationError, "Qiita :::note") {
+ t.Fatalf("initial attempt did not preserve validation failure: %#v", result.Attempts[0])
+ }
+ if result.Attempts[1].Kind != "format_repair" || result.Attempts[1].RawOutput != repairedZenn || result.Attempts[1].ValidationError != "" {
+ t.Fatalf("repair attempt not preserved correctly: %#v", result.Attempts[1])
+ }
+ for _, want := range []string{
+ invalidZenn,
+ "zenn article must use :::message, not Qiita :::note",
+ "Use this guide only for `zenn_article` output.",
+ "修正版の記事本文だけ",
+ } {
+ if !strings.Contains(generator.prompts[1], want) {
+ t.Fatalf("repair prompt missing %q:\n%s", want, generator.prompts[1])
+ }
+ }
+}
+
+func TestRecoverableFormatValidationErrorsAreBoundedToKnownCases(t *testing.T) {
+ tests := []struct {
+ name string
+ formatID string
+ err error
+ want bool
+ }{
+ {
+ name: "preamble",
+ formatID: outputformat.IDNoteArticle,
+ err: errors.New("draft appears to contain preamble before the article"),
+ want: true,
+ },
+ {
+ name: "zenn qiita note",
+ formatID: outputformat.IDZennArticle,
+ err: errors.New("zenn article must use :::message, not Qiita :::note"),
+ want: true,
+ },
+ {
+ name: "qiita zenn notation",
+ formatID: outputformat.IDQiitaArticle,
+ err: errors.New("qiita article must not contain Zenn-specific notation"),
+ want: true,
+ },
+ {
+ name: "missing title stays strict",
+ formatID: outputformat.IDNoteArticle,
+ err: errors.New("note article must start with a level-1 Markdown title"),
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isRecoverableFormatValidationError(tt.formatID, tt.err); got != tt.want {
+ t.Fatalf("recoverable = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestPromptIncludesFormatGuideForEveryRegisteredFormat(t *testing.T) {
profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
persona, _ := personadomain.DefaultRegistry().Get(personadomain.IDTerisuke)
@@ -332,7 +433,8 @@ func TestGenerateUsesCorBlogOutputRules(t *testing.T) {
}
func TestGenerateRejectsUnusableMarkdown(t *testing.T) {
- service := NewService(&fakeGenerator{draft: "承知しました。記事を書きます。"})
+ generator := &sequenceGenerator{drafts: []string{"承知しました。記事を書きます。", matchingDraft()}}
+ service := NewService(generator)
profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
_, err := service.Generate(context.Background(), GenerateRequest{
@@ -343,6 +445,16 @@ func TestGenerateRejectsUnusableMarkdown(t *testing.T) {
if err == nil {
t.Fatal("expected unusable draft error")
}
+ if generator.calls != 1 {
+ t.Fatalf("calls = %d, want no repair retry", generator.calls)
+ }
+ var unusable *UnusableDraftError
+ if !errors.As(err, &unusable) {
+ t.Fatalf("expected UnusableDraftError, got %T", err)
+ }
+ if len(unusable.Attempts) != 1 || unusable.Attempts[0].RawOutput != "承知しました。記事を書きます。" {
+ t.Fatalf("raw validation failure was not preserved: %#v", unusable.Attempts)
+ }
}
func TestEvaluateStylePassesStrictThresholdsAndOverride(t *testing.T) {
diff --git a/internal/application/draft/types.go b/internal/application/draft/types.go
index e686402..8a1a4a4 100644
--- a/internal/application/draft/types.go
+++ b/internal/application/draft/types.go
@@ -34,6 +34,15 @@ type GenerateResult struct {
Draft articledomain.Draft
Evaluation StyleEvaluation
Verification FinalVerification
+ Attempts []GenerationAttempt
+}
+
+// GenerationAttempt preserves raw model output and validation state for each model call.
+type GenerationAttempt struct {
+ Index int `json:"index"`
+ Kind string `json:"kind"`
+ RawOutput string `json:"raw_output"`
+ ValidationError string `json:"validation_error,omitempty"`
}
// FinalVerification reports the lightweight model's final consistency review.
diff --git a/internal/domain/article/draft.go b/internal/domain/article/draft.go
index 5811f1b..17273ee 100644
--- a/internal/domain/article/draft.go
+++ b/internal/domain/article/draft.go
@@ -36,7 +36,7 @@ func NewDraftForFormat(raw, formatID string) (Draft, error) {
if err := format.Validator.Validate(markdown); err != nil {
return Draft{}, err
}
- if strings.Contains(markdown, "以下") && strings.Contains(markdown, "下書き") && strings.Index(markdown, "# ") > 20 {
+ if !strings.HasPrefix(markdown, "---\n") && strings.Contains(markdown, "以下") && strings.Contains(markdown, "下書き") && strings.Index(markdown, "# ") > 20 {
return Draft{}, fmt.Errorf("draft appears to contain preamble before the article")
}
return Draft{markdown: markdown}, nil
@@ -58,7 +58,7 @@ func normalizeDraft(raw string) string {
text = strings.TrimSpace(match[1])
}
droppedPreambleWithFence := false
- if idx := strings.Index(text, "# "); idx > 0 {
+ if idx := strings.Index(text, "# "); idx > 0 && !strings.HasPrefix(text, "---\n") {
preamble := strings.TrimSpace(text[:idx])
if looksLikePreamble(preamble) && canDropPreamble(preamble) {
droppedPreambleWithFence = strings.Contains(preamble, "```")
diff --git a/internal/domain/article/draft_test.go b/internal/domain/article/draft_test.go
index 809dce8..6ad1d5e 100644
--- a/internal/domain/article/draft_test.go
+++ b/internal/domain/article/draft_test.go
@@ -30,6 +30,22 @@ func TestNewDraftForFormatAllowsTechnicalFormats(t *testing.T) {
}
}
+func TestNewDraftForFormatDoesNotTreatFrontmatterBodyAsPreamble(t *testing.T) {
+ zenn := "---\n" +
+ "title: \"Goで試す\"\n" +
+ "emoji: \"🧪\"\n" +
+ "type: \"tech\"\n" +
+ "topics: [\"go\", \"test\"]\n" +
+ "published: false\n" +
+ "---\n\n" +
+ "本文では以下の下書きを検証する。\n\n" +
+ "## 実装\n\n" +
+ ":::message\n補足\n:::"
+ if _, err := NewDraftForFormat(zenn, "zenn_article"); err != nil {
+ t.Fatalf("frontmatter format should not be rejected as assistant preamble: %v", err)
+ }
+}
+
func TestNewDraftRejectsNonArticleOutput(t *testing.T) {
if _, err := NewDraft("承知しました。記事を書きます。"); err == nil {
t.Fatal("expected validation error")
diff --git a/internal/domain/format/format.go b/internal/domain/format/format.go
index 32846f0..d2e984b 100644
--- a/internal/domain/format/format.go
+++ b/internal/domain/format/format.go
@@ -237,13 +237,18 @@ func (ZennValidator) Validate(markdown string) error {
if err := validateInlineListLimit(frontmatter, "topics", 5); err != nil {
return fmt.Errorf("zenn %w", err)
}
- if strings.Contains(markdown, ":::note") {
+ if containsLineOutsideCodeFence(markdown, func(line string) bool {
+ return strings.HasPrefix(strings.TrimSpace(line), ":::note")
+ }) {
return fmt.Errorf("zenn article must use :::message, not Qiita :::note")
}
if strings.Contains(markdown, "```diff_") {
return fmt.Errorf("zenn diff code fences use `diff language`, not diff_language")
}
- if containsAny(markdown, []string{"
Date: Sun, 3 May 2026 14:18:05 +0900
Subject: [PATCH 25/33] Stabilize Evo X2 media matrix style gates (#78)
---
cmd/scenario/draft_generation/main.go | 24 +-
cmd/scenario/draft_generation/main_test.go | 29 +
cmd/scenario/live_media_matrix/main.go | 556 ++++++++++++++++--
cmd/scenario/live_media_matrix/main_test.go | 176 +++++-
cmd/scenario/media_matrix/main.go | 116 +++-
cmd/scenario/media_matrix/main_test.go | 33 +-
...02-multi-persona-multi-format-extension.md | 12 +-
.../next-implementation-cut.md | 59 +-
...issue-74-staged-evo-x2-rerun-2026-05-03.md | 72 ++-
internal/application/draft/evaluation.go | 101 +++-
internal/application/draft/evaluation_test.go | 119 ++++
internal/application/draft/section.go | 2 +-
internal/application/draft/service.go | 14 +-
internal/application/draft/service_test.go | 91 ++-
internal/handlers/workflow.go | 209 ++++++-
internal/handlers/workflow_stream_test.go | 139 +++++
static/js/script.js | 39 +-
17 files changed, 1668 insertions(+), 123 deletions(-)
create mode 100644 internal/application/draft/evaluation_test.go
diff --git a/cmd/scenario/draft_generation/main.go b/cmd/scenario/draft_generation/main.go
index 5cc5d9b..33ff4e8 100644
--- a/cmd/scenario/draft_generation/main.go
+++ b/cmd/scenario/draft_generation/main.go
@@ -39,6 +39,9 @@ func main() {
readJSON(profilePath, &profile)
readJSON(guidePath, &guide)
readJSON(briefPath, &brief)
+ if err := validateScenarioInputs(profile, guide, brief); err != nil {
+ fatalf("invalid scenario inputs: %v", err)
+ }
baseURL := envFirst("http://127.0.0.1:8081/v1", "LLM_BASE_URL", "LLAMACPP_BASE_URL")
model := envFirst("gemma4:31b", "DRAFT_LLM_MODEL", "LLM_MODEL", "LLAMACPP_MODEL")
@@ -116,7 +119,7 @@ func main() {
writeFile(filepath.Join(outputDir, fmt.Sprintf("draft_attempt_%d.md", attempt)), result.Draft.Markdown()+"\n")
writeJSON(filepath.Join(outputDir, fmt.Sprintf("evaluation_attempt_%d.json", attempt)), result.Evaluation)
writeJSON(filepath.Join(outputDir, fmt.Sprintf("verification_attempt_%d.json", attempt)), result.Verification)
- if result.Evaluation.Comparison.Score >= minStyleScore && len([]rune(result.Draft.Markdown())) >= minDraftRunes {
+ if result.Evaluation.Comparison.Score >= minStyleScore && len([]rune(result.Draft.Markdown())) >= minDraftRunes && verificationGatePassed(result.Verification) {
break
}
}
@@ -126,7 +129,7 @@ func main() {
writeJSON(filepath.Join(outputDir, "verification.json"), result.Verification)
runes := len([]rune(result.Draft.Markdown()))
- passesScenario := result.Evaluation.Comparison.Score >= minStyleScore && runes >= minDraftRunes
+ passesScenario := result.Evaluation.Comparison.Score >= minStyleScore && runes >= minDraftRunes && verificationGatePassed(result.Verification)
fmt.Printf("draft generation scenario completed\n")
fmt.Printf("scenario_passed=%v\n", passesScenario)
fmt.Printf("attempt=%d\n", finalAttempt)
@@ -156,6 +159,9 @@ func main() {
if runes < minDraftRunes {
fatalf("draft length %d below scenario minimum %d", runes, minDraftRunes)
}
+ if result.Verification.Performed && !result.Verification.Passed {
+ fatalf("final verification failed: %s", result.Verification.Summary)
+ }
}
type attemptRuntimeMetrics struct {
@@ -246,6 +252,20 @@ func validationErrorFromGenerateError(err error) string {
return ""
}
+func verificationGatePassed(verification draftapp.FinalVerification) bool {
+ return !verification.Performed || verification.Passed
+}
+
+func validateScenarioInputs(profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, brief briefdomain.ArticleBrief) error {
+ if strings.TrimSpace(guide.ProfileID) != strings.TrimSpace(profile.ID) {
+ return fmt.Errorf("writing guide profile id %q does not match author profile id %q", guide.ProfileID, profile.ID)
+ }
+ if strings.TrimSpace(brief.StyleProfileID) != "" && strings.TrimSpace(brief.StyleProfileID) != strings.TrimSpace(profile.ID) {
+ return fmt.Errorf("article brief style profile id %q does not match author profile id %q", brief.StyleProfileID, profile.ID)
+ }
+ return nil
+}
+
func finalFirstChunkMs(firstChunk time.Duration) int64 {
if firstChunk <= 0 {
return 0
diff --git a/cmd/scenario/draft_generation/main_test.go b/cmd/scenario/draft_generation/main_test.go
index 5e938d1..616c1c1 100644
--- a/cmd/scenario/draft_generation/main_test.go
+++ b/cmd/scenario/draft_generation/main_test.go
@@ -8,6 +8,8 @@ import (
"testing"
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"
)
func TestWriteFailureAttemptPreservesRawOutputsAndRuntimeMetrics(t *testing.T) {
@@ -91,3 +93,30 @@ func TestWriteFailureAttemptPreservesRawOutputsAndRuntimeMetrics(t *testing.T) {
t.Fatalf("raw output metadata not preserved: %#v", report.RawOutputs)
}
}
+
+func TestValidateScenarioInputsRejectsMismatchedStyleArtifacts(t *testing.T) {
+ profile := authordomain.AuthorStyleProfile{ID: "profile_case"}
+ guide := authordomain.WritingStyleGuide{ProfileID: "other_profile"}
+ brief := briefdomain.ArticleBrief{StyleProfileID: "profile_case"}
+
+ err := validateScenarioInputs(profile, guide, brief)
+ if err == nil {
+ t.Fatal("expected guide/profile mismatch")
+ }
+
+ guide.ProfileID = "profile_case"
+ brief.StyleProfileID = "other_profile"
+ err = validateScenarioInputs(profile, guide, brief)
+ if err == nil {
+ t.Fatal("expected brief/profile mismatch")
+ }
+}
+
+func TestVerificationGateFailsPerformedFailedVerification(t *testing.T) {
+ if !verificationGatePassed(draftapp.FinalVerification{}) {
+ t.Fatal("not-performed verification should not block scenario")
+ }
+ if verificationGatePassed(draftapp.FinalVerification{Performed: true, Passed: false}) {
+ t.Fatal("performed failed verification should block scenario")
+ }
+}
diff --git a/cmd/scenario/live_media_matrix/main.go b/cmd/scenario/live_media_matrix/main.go
index 967ec06..16802df 100644
--- a/cmd/scenario/live_media_matrix/main.go
+++ b/cmd/scenario/live_media_matrix/main.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "math"
"os"
"os/exec"
"path/filepath"
@@ -14,8 +15,9 @@ import (
)
const (
- defaultMatrixDir = "tmp/media_matrix"
- defaultLiveDir = "tmp/media_matrix/live"
+ defaultMatrixDir = "tmp/media_matrix"
+ defaultLiveDir = "tmp/media_matrix/live"
+ offlineGeneratedAt = "1970-01-01T00:00:00Z"
)
type matrixOutput struct {
@@ -32,19 +34,69 @@ type matrixCase struct {
TargetLengthStructure string `json:"target_length_structure"`
SourceSelectors []string `json:"source_selectors"`
BriefPath string `json:"brief_path"`
+ ProfilePath string `json:"profile_path"`
+ GuidePath string `json:"guide_path"`
PromptPath string `json:"prompt_path"`
ActiveGates scenarioGates `json:"active_gates"`
}
type aggregateReport struct {
- GeneratedBy string `json:"generated_by"`
- Live bool `json:"live"`
- MatrixPath string `json:"matrix_path"`
- GeneratedAt string `json:"generated_at"`
- Rows []resultRow `json:"rows"`
+ GeneratedBy string `json:"generated_by"`
+ Live bool `json:"live"`
+ MatrixPath string `json:"matrix_path"`
+ GeneratedAt string `json:"generated_at"`
+ SelectedCaseIDs []string `json:"selected_case_ids"`
+ RunOrdinals []int `json:"run_ordinals"`
+ RepeatRuns int `json:"repeat_runs"`
+ Runtime runtimeMetadata `json:"runtime"`
+ Summary aggregateSummary `json:"summary"`
+ Rows []resultRow `json:"rows"`
+}
+
+type runtimeMetadata struct {
+ LLMBaseURLs []string `json:"llm_base_urls,omitempty"`
+ LLMModels []string `json:"llm_models,omitempty"`
+ VerifyModels []string `json:"verify_models,omitempty"`
+ TailnetEvoX2 bool `json:"tailnet_evo_x2"`
+}
+
+type aggregateSummary struct {
+ TotalRows int `json:"total_rows"`
+ SelectedCases int `json:"selected_cases"`
+ PassedRows int `json:"passed_rows"`
+ FailedRows int `json:"failed_rows"`
+ PlannedRows int `json:"planned_rows"`
+ SkippedRows int `json:"skipped_rows"`
+ OtherRows int `json:"other_rows"`
+ ConciseRows []conciseAggregateRow `json:"concise_rows"`
+ PassFailGroups []outcomeGroup `json:"pass_fail_groups"`
+}
+
+type conciseAggregateRow struct {
+ RunOrdinal int `json:"run_ordinal"`
+ SelectedCases int `json:"selected_cases"`
+ PassedRows int `json:"passed_rows"`
+ FailedRows int `json:"failed_rows"`
+ PlannedRows int `json:"planned_rows"`
+ SkippedRows int `json:"skipped_rows"`
+ OtherRows int `json:"other_rows"`
+ AverageSeconds float64 `json:"average_seconds,omitempty"`
+ AverageScore float64 `json:"average_score,omitempty"`
+ AverageRunes int `json:"average_runes,omitempty"`
+ LLMBaseURLs []string `json:"llm_base_urls,omitempty"`
+ LLMModels []string `json:"llm_models,omitempty"`
+ VerifyModels []string `json:"verify_models,omitempty"`
+}
+
+type outcomeGroup struct {
+ Outcome string `json:"outcome"`
+ Count int `json:"count"`
+ CaseRunIDs []string `json:"case_run_ids"`
}
type resultRow struct {
+ RunOrdinal int `json:"run_ordinal"`
+ ComparisonKey string `json:"comparison_key"`
CaseID string `json:"case_id"`
Medium string `json:"medium"`
Style string `json:"style"`
@@ -55,6 +107,7 @@ type resultRow struct {
SourceSelectors []string `json:"source_selectors"`
Status string `json:"status"`
ActiveGates scenarioGates `json:"active_gates"`
+ FailureGroup string `json:"failure_group,omitempty"`
ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
FirstChunkMS int `json:"first_chunk_ms,omitempty"`
Chunks int `json:"chunks,omitempty"`
@@ -70,6 +123,8 @@ type resultRow struct {
LLMModel string `json:"llm_model,omitempty"`
VerifyModel string `json:"verify_model,omitempty"`
OutputDir string `json:"output_dir"`
+ ProfilePath string `json:"profile_path,omitempty"`
+ GuidePath string `json:"guide_path,omitempty"`
DraftPath string `json:"draft_path,omitempty"`
EvaluationPath string `json:"evaluation_path,omitempty"`
VerificationPath string `json:"verification_path,omitempty"`
@@ -96,27 +151,37 @@ func main() {
matrix := readMatrix(matrixPath)
live := os.Getenv("RUN_LIVE_MEDIA_MATRIX") == "1"
selected := selectedCaseIDs(os.Getenv("LIVE_MEDIA_MATRIX_CASES"))
- rows := make([]resultRow, 0, len(matrix.Cases))
- for _, item := range matrix.Cases {
- if len(selected) > 0 && !selected[item.ID] {
- continue
- }
- if !live {
- rows = append(rows, plannedRow(item, filepath.Join(liveDir, item.ID)))
- continue
+ cases := selectedCases(matrix.Cases, selected)
+ startOrdinal := positiveEnvInt("LIVE_MEDIA_MATRIX_RUN_ORDINAL", 1)
+ repeatRuns := positiveEnvInt("LIVE_MEDIA_MATRIX_REPEAT_RUNS", 1)
+ runOrdinals := runOrdinalRange(startOrdinal, repeatRuns)
+ rows := make([]resultRow, 0, len(cases)*repeatRuns)
+ for _, ordinal := range runOrdinals {
+ for _, item := range cases {
+ outputDir := outputDirForRun(liveDir, item.ID, ordinal, repeatRuns)
+ if !live {
+ rows = append(rows, plannedRowForRun(item, outputDir, ordinal))
+ continue
+ }
+ rows = append(rows, runCaseForRun(item, outputDir, ordinal))
}
- rows = append(rows, runCase(item, filepath.Join(liveDir, item.ID)))
}
if len(rows) == 0 {
fatalf("no media matrix cases selected")
}
+ selectedIDs := caseIDs(cases)
report := aggregateReport{
- GeneratedBy: "cmd/scenario/live_media_matrix",
- Live: live,
- MatrixPath: matrixPath,
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- Rows: rows,
+ GeneratedBy: "cmd/scenario/live_media_matrix",
+ Live: live,
+ MatrixPath: matrixPath,
+ GeneratedAt: generatedAt(live),
+ SelectedCaseIDs: selectedIDs,
+ RunOrdinals: runOrdinals,
+ RepeatRuns: repeatRuns,
+ Runtime: runtimeFromRows(rows),
+ Summary: summarizeRows(rows, len(selectedIDs)),
+ Rows: rows,
}
writeJSON(filepath.Join(liveDir, "aggregate.json"), report)
writeFile(filepath.Join(liveDir, "aggregate.md"), markdownReport(report))
@@ -147,7 +212,13 @@ func readMatrix(path string) matrixOutput {
}
func plannedRow(item matrixCase, outputDir string) resultRow {
+ return plannedRowForRun(item, outputDir, 1)
+}
+
+func plannedRowForRun(item matrixCase, outputDir string, runOrdinal int) resultRow {
return resultRow{
+ RunOrdinal: runOrdinal,
+ ComparisonKey: comparisonKey(runOrdinal, item.ID),
CaseID: item.ID,
Medium: item.Medium,
Style: item.Style,
@@ -161,18 +232,26 @@ func plannedRow(item matrixCase, outputDir string) resultRow {
MinStyleScore: item.ActiveGates.MinStyleScore,
MinRunes: item.ActiveGates.MinRunes,
OutputDir: outputDir,
+ ProfilePath: item.ProfilePath,
+ GuidePath: item.GuidePath,
}
}
func runCase(item matrixCase, outputDir string) resultRow {
- row := plannedRow(item, outputDir)
+ return runCaseForRun(item, outputDir, 1)
+}
+
+func runCaseForRun(item matrixCase, outputDir string, runOrdinal int) resultRow {
+ row := plannedRowForRun(item, outputDir, runOrdinal)
row.Status = "running"
+ applyRuntimeEnv(&row)
if envBool("LIVE_MEDIA_MATRIX_RESUME") && fileExists(filepath.Join(outputDir, "draft.md")) {
row.Status = "skipped_existing"
row.DraftPath = filepath.Join(outputDir, "draft.md")
row.EvaluationPath = filepath.Join(outputDir, "evaluation.json")
row.VerificationPath = filepath.Join(outputDir, "verification.json")
applyRunMetrics(&row, readKeyValuesFile(filepath.Join(outputDir, "stdout.txt")), item.ActiveGates)
+ applyStructuralGates(&row)
return row
}
if err := os.RemoveAll(outputDir); err != nil {
@@ -199,6 +278,7 @@ func runCase(item matrixCase, outputDir string) resultRow {
writeFile(filepath.Join(outputDir, "stderr.txt"), stderr.String())
applyRunMetrics(&row, parseKeyValues(stdout.String()), item.ActiveGates)
+ applyStructuralGates(&row)
if err != nil {
row.Status = "failed"
row.Error = strings.TrimSpace(stderr.String())
@@ -206,12 +286,92 @@ func runCase(item matrixCase, outputDir string) resultRow {
row.Error = err.Error()
}
applyFailureArtifacts(&row, outputDir)
+ if row.FailureGroup == "" {
+ row.FailureGroup = failureGroup(row)
+ }
+ return row
+ }
+ if row.Status == "running" {
+ row.Status = "passed"
+ }
+ if rowOutcome(row) != "passed" {
+ row.Status = "failed"
+ if row.FailureGroup == "" {
+ row.FailureGroup = failureGroup(row)
+ }
return row
}
- row.Status = "passed"
return row
}
+func selectedCases(cases []matrixCase, selected map[string]bool) []matrixCase {
+ if len(selected) == 0 {
+ return append([]matrixCase(nil), cases...)
+ }
+ filtered := make([]matrixCase, 0, len(selected))
+ for _, item := range cases {
+ if selected[item.ID] {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
+
+func caseIDs(cases []matrixCase) []string {
+ ids := make([]string, 0, len(cases))
+ for _, item := range cases {
+ ids = append(ids, item.ID)
+ }
+ return ids
+}
+
+func outputDirForRun(liveDir, caseID string, runOrdinal, repeatRuns int) string {
+ if repeatRuns == 1 && runOrdinal == 1 {
+ return filepath.Join(liveDir, caseID)
+ }
+ return filepath.Join(liveDir, fmt.Sprintf("run_%02d", runOrdinal), caseID)
+}
+
+func runOrdinalRange(startOrdinal, repeatRuns int) []int {
+ ordinals := make([]int, 0, repeatRuns)
+ for offset := 0; offset < repeatRuns; offset++ {
+ ordinals = append(ordinals, startOrdinal+offset)
+ }
+ return ordinals
+}
+
+func comparisonKey(runOrdinal int, caseID string) string {
+ return fmt.Sprintf("run_%02d/%s", runOrdinal, caseID)
+}
+
+func positiveEnvInt(name string, fallback int) int {
+ value := strings.TrimSpace(os.Getenv(name))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 {
+ fatalf("%s must be a positive integer", name)
+ }
+ return parsed
+}
+
+func generatedAt(live bool) string {
+ if value := strings.TrimSpace(os.Getenv("LIVE_MEDIA_MATRIX_GENERATED_AT")); value != "" {
+ return value
+ }
+ if !live {
+ return offlineGeneratedAt
+ }
+ return time.Now().UTC().Format(time.RFC3339)
+}
+
+func applyRuntimeEnv(row *resultRow) {
+ row.LLMBaseURL = valueOrDefault(row.LLMBaseURL, os.Getenv("LLM_BASE_URL"))
+ row.LLMModel = valueOrDefault(row.LLMModel, valueOrDefault(os.Getenv("DRAFT_LLM_MODEL"), os.Getenv("LLM_MODEL")))
+ row.VerifyModel = valueOrDefault(row.VerifyModel, os.Getenv("VERIFY_LLM_MODEL"))
+}
+
func applyRunMetrics(row *resultRow, values map[string]string, gates scenarioGates) {
if len(values) == 0 {
return
@@ -227,12 +387,49 @@ func applyRunMetrics(row *resultRow, values map[string]string, gates scenarioGat
row.ScenarioPassed = boolValue(values["scenario_passed"])
row.VerificationPerformed = boolValue(values["verification_performed"])
row.VerificationPassed = boolValue(values["verification_passed"])
- row.LLMBaseURL = values["llm_base_url"]
- row.LLMModel = values["llm_model"]
- row.VerifyModel = values["verify_model"]
+ row.LLMBaseURL = valueOrDefault(values["llm_base_url"], row.LLMBaseURL)
+ row.LLMModel = valueOrDefault(values["llm_model"], row.LLMModel)
+ row.VerifyModel = valueOrDefault(values["verify_model"], row.VerifyModel)
row.DraftPath = valueOrDefault(values["draft"], row.DraftPath)
row.EvaluationPath = valueOrDefault(values["evaluation"], row.EvaluationPath)
row.VerificationPath = valueOrDefault(values["verification"], row.VerificationPath)
+ row.FailureGroup = failureGroup(*row)
+}
+
+func applyStructuralGates(row *resultRow) {
+ if len(row.ActiveGates.StructuralSignals) == 0 || strings.TrimSpace(row.DraftPath) == "" {
+ return
+ }
+ content, err := os.ReadFile(row.DraftPath)
+ if err != nil {
+ return
+ }
+ missing := missingStructuralSignals(string(content), row.ActiveGates.StructuralSignals)
+ if len(missing) == 0 {
+ return
+ }
+ row.Status = "failed"
+ row.ScenarioPassed = false
+ row.FailureGroup = "structural_gate"
+ detail := "missing structural signals: " + strings.Join(missing, ", ")
+ if strings.TrimSpace(row.Error) == "" {
+ row.Error = detail
+ } else if !strings.Contains(row.Error, detail) {
+ row.Error += "\n" + detail
+ }
+}
+
+func missingStructuralSignals(content string, signals []string) []string {
+ missing := make([]string, 0)
+ for _, signal := range signals {
+ if strings.TrimSpace(signal) == "" {
+ continue
+ }
+ if !strings.Contains(content, signal) {
+ missing = append(missing, signal)
+ }
+ }
+ return missing
}
func draftGenerationEnv(item matrixCase, outputDir string) []string {
@@ -241,6 +438,12 @@ func draftGenerationEnv(item matrixCase, outputDir string) []string {
"ARTICLE_BRIEF_PATH="+item.BriefPath,
"SCENARIO_OUTPUT_DIR="+outputDir,
)
+ if strings.TrimSpace(item.ProfilePath) != "" {
+ env = append(env, "AUTHOR_PROFILE_PATH="+item.ProfilePath)
+ }
+ if strings.TrimSpace(item.GuidePath) != "" {
+ env = append(env, "WRITING_GUIDE_PATH="+item.GuidePath)
+ }
if item.ActiveGates.MinStyleScore > 0 {
env = append(env, fmt.Sprintf("SCENARIO_MIN_STYLE_SCORE=%.1f", item.ActiveGates.MinStyleScore))
}
@@ -250,6 +453,23 @@ func draftGenerationEnv(item matrixCase, outputDir string) []string {
return env
}
+func failureGroup(row resultRow) string {
+ switch {
+ case strings.TrimSpace(row.FailurePath) != "":
+ return "generation_or_validation"
+ case row.VerificationPerformed && !row.VerificationPassed:
+ return "final_verification"
+ case row.Score > 0 && row.MinStyleScore > 0 && row.Score < row.MinStyleScore:
+ return "style_score"
+ case row.Runes > 0 && row.MinRunes > 0 && row.Runes < row.MinRunes:
+ return "draft_length"
+ case strings.TrimSpace(row.Error) != "":
+ return "runtime_or_runner"
+ default:
+ return ""
+ }
+}
+
type failureAttemptReport struct {
RuntimeMetrics attemptRuntimeMetrics `json:"runtime_metrics"`
Context failureContext `json:"context"`
@@ -304,6 +524,7 @@ func applyFailureArtifacts(row *resultRow, outputDir string) {
row.RawOutputPaths = append(row.RawOutputPaths, raw.Path)
}
}
+ row.FailureGroup = failureGroup(*row)
}
func latestFailureAttemptPath(outputDir string) string {
@@ -336,18 +557,56 @@ func readKeyValuesFile(path string) map[string]string {
}
func markdownReport(report aggregateReport) string {
+ summary := report.Summary
+ if summary.TotalRows == 0 {
+ summary = summarizeRows(report.Rows, len(report.SelectedCaseIDs))
+ }
var builder strings.Builder
builder.WriteString("# Live media matrix aggregate\n\n")
builder.WriteString(fmt.Sprintf("- Generated at: `%s`\n", report.GeneratedAt))
builder.WriteString(fmt.Sprintf("- Live mode: `%v`\n", report.Live))
- builder.WriteString(fmt.Sprintf("- Matrix: `%s`\n\n", report.MatrixPath))
- builder.WriteString("| Case | Medium | Style | Status | Gates | Seconds | Score | Runes | Verification | Output |\n")
- builder.WriteString("|---|---|---|---|---|---:|---:|---:|---|---|\n")
+ builder.WriteString(fmt.Sprintf("- Matrix: `%s`\n", report.MatrixPath))
+ builder.WriteString(fmt.Sprintf("- Selected cases: `%s`\n", strings.Join(report.SelectedCaseIDs, ", ")))
+ builder.WriteString(fmt.Sprintf("- Run ordinals: `%s`\n", joinInts(report.RunOrdinals)))
+ builder.WriteString(fmt.Sprintf("- Tailnet Evo X2: `%v`\n\n", report.Runtime.TailnetEvoX2))
+
+ builder.WriteString("## Summary\n\n")
+ builder.WriteString("| Run | Cases | Passed | Failed | Planned | Skipped | Seconds | Score | Runes | Runtime |\n")
+ builder.WriteString("|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|\n")
+ for _, row := range summary.ConciseRows {
+ builder.WriteString(fmt.Sprintf("| %d | %d | %d | %d | %d | %d | %.2f | %.1f | %d | %s |\n",
+ row.RunOrdinal,
+ row.SelectedCases,
+ row.PassedRows,
+ row.FailedRows,
+ row.PlannedRows,
+ row.SkippedRows,
+ row.AverageSeconds,
+ row.AverageScore,
+ row.AverageRunes,
+ escapePipes(runtimeLabel(row.LLMBaseURLs, row.LLMModels)),
+ ))
+ }
+
+ for _, group := range summary.PassFailGroups {
+ if group.Count == 0 {
+ continue
+ }
+ builder.WriteString(fmt.Sprintf("\n## %s\n\n", titleOutcome(group.Outcome)))
+ builder.WriteString(fmt.Sprintf("Cases: `%s`\n", strings.Join(group.CaseRunIDs, ", ")))
+ builder.WriteString("\n")
+ }
+
+ builder.WriteString("## Case Results\n\n")
+ builder.WriteString("| Run | Case | Medium | Style | Outcome | Status | Gates | Seconds | Score | Runes | Verification | Output |\n")
+ builder.WriteString("|---:|---|---|---|---|---|---|---:|---:|---:|---|---|\n")
for _, row := range report.Rows {
- builder.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s | %.2f | %.1f / %.1f | %d / %d | %v | `%s` |\n",
+ builder.WriteString(fmt.Sprintf("| %d | `%s` | %s | %s | %s | %s | %s | %.2f | %.1f / %.1f | %d / %d | %v | `%s` |\n",
+ row.RunOrdinal,
row.CaseID,
escapePipes(row.Medium),
escapePipes(row.Style),
+ rowOutcome(row),
row.Status,
escapePipes(gateSummary(row.ActiveGates)),
row.ElapsedSeconds,
@@ -361,14 +620,175 @@ func markdownReport(report aggregateReport) string {
}
failures := failedRows(report.Rows)
if len(failures) > 0 {
- builder.WriteString("\n## Failures\n\n")
+ builder.WriteString("\n## Failure Details\n\n")
for _, row := range failures {
- builder.WriteString(fmt.Sprintf("- `%s`: %s\n", row.CaseID, strings.TrimSpace(row.Error)))
+ builder.WriteString(fmt.Sprintf("- Run %d `%s`: %s\n", row.RunOrdinal, row.CaseID, failureReason(row)))
}
}
return builder.String()
}
+func summarizeRows(rows []resultRow, selectedCases int) aggregateSummary {
+ if selectedCases == 0 {
+ selectedCases = len(uniqueCaseIDs(rows))
+ }
+ summary := aggregateSummary{
+ TotalRows: len(rows),
+ SelectedCases: selectedCases,
+ }
+ byRun := map[int][]resultRow{}
+ groups := map[string][]string{}
+ for _, row := range rows {
+ outcome := rowOutcome(row)
+ switch outcome {
+ case "passed":
+ summary.PassedRows++
+ case "failed":
+ summary.FailedRows++
+ case "planned":
+ summary.PlannedRows++
+ case "skipped_existing":
+ summary.SkippedRows++
+ default:
+ summary.OtherRows++
+ }
+ byRun[row.RunOrdinal] = append(byRun[row.RunOrdinal], row)
+ groups[outcome] = append(groups[outcome], row.ComparisonKey)
+ }
+ ordinals := make([]int, 0, len(byRun))
+ for ordinal := range byRun {
+ ordinals = append(ordinals, ordinal)
+ }
+ sort.Ints(ordinals)
+ for _, ordinal := range ordinals {
+ summary.ConciseRows = append(summary.ConciseRows, summarizeRunRows(ordinal, byRun[ordinal], selectedCases))
+ }
+ for _, outcome := range []string{"failed", "passed", "planned", "skipped_existing", "other"} {
+ caseIDs := sortedUnique(groups[outcome])
+ summary.PassFailGroups = append(summary.PassFailGroups, outcomeGroup{
+ Outcome: outcome,
+ Count: len(caseIDs),
+ CaseRunIDs: caseIDs,
+ })
+ }
+ return summary
+}
+
+func summarizeRunRows(runOrdinal int, rows []resultRow, selectedCases int) conciseAggregateRow {
+ row := conciseAggregateRow{
+ RunOrdinal: runOrdinal,
+ SelectedCases: selectedCases,
+ }
+ var secondsTotal float64
+ var secondsCount int
+ var scoreTotal float64
+ var scoreCount int
+ var runeTotal int
+ var runeCount int
+ for _, item := range rows {
+ switch rowOutcome(item) {
+ case "passed":
+ row.PassedRows++
+ case "failed":
+ row.FailedRows++
+ case "planned":
+ row.PlannedRows++
+ case "skipped_existing":
+ row.SkippedRows++
+ default:
+ row.OtherRows++
+ }
+ if item.ElapsedSeconds > 0 {
+ secondsTotal += item.ElapsedSeconds
+ secondsCount++
+ }
+ if item.Score > 0 {
+ scoreTotal += item.Score
+ scoreCount++
+ }
+ if item.Runes > 0 {
+ runeTotal += item.Runes
+ runeCount++
+ }
+ row.LLMBaseURLs = appendIfPresent(row.LLMBaseURLs, item.LLMBaseURL)
+ row.LLMModels = appendIfPresent(row.LLMModels, item.LLMModel)
+ row.VerifyModels = appendIfPresent(row.VerifyModels, item.VerifyModel)
+ }
+ row.LLMBaseURLs = sortedUnique(row.LLMBaseURLs)
+ row.LLMModels = sortedUnique(row.LLMModels)
+ row.VerifyModels = sortedUnique(row.VerifyModels)
+ row.AverageSeconds = averageFloat(secondsTotal, secondsCount)
+ row.AverageScore = averageFloat(scoreTotal, scoreCount)
+ row.AverageRunes = averageInt(runeTotal, runeCount)
+ return row
+}
+
+func runtimeFromRows(rows []resultRow) runtimeMetadata {
+ var runtime runtimeMetadata
+ for _, row := range rows {
+ runtime.LLMBaseURLs = appendIfPresent(runtime.LLMBaseURLs, row.LLMBaseURL)
+ runtime.LLMModels = appendIfPresent(runtime.LLMModels, row.LLMModel)
+ runtime.VerifyModels = appendIfPresent(runtime.VerifyModels, row.VerifyModel)
+ }
+ runtime.LLMBaseURLs = sortedUnique(runtime.LLMBaseURLs)
+ runtime.LLMModels = sortedUnique(runtime.LLMModels)
+ runtime.VerifyModels = sortedUnique(runtime.VerifyModels)
+ for _, baseURL := range runtime.LLMBaseURLs {
+ if isTailnetEvoX2(baseURL) {
+ runtime.TailnetEvoX2 = true
+ break
+ }
+ }
+ return runtime
+}
+
+func isTailnetEvoX2(baseURL string) bool {
+ cleaned := strings.ToLower(strings.TrimSpace(baseURL))
+ return strings.Contains(cleaned, "evo-x2") && !strings.Contains(cleaned, "127.0.0.1") && !strings.Contains(cleaned, "localhost")
+}
+
+func rowOutcome(row resultRow) string {
+ switch row.Status {
+ case "planned":
+ return "planned"
+ case "failed":
+ return "failed"
+ case "passed":
+ if row.ScenarioPassed {
+ return "passed"
+ }
+ return "failed"
+ case "skipped_existing":
+ if row.ScenarioPassed || row.Passed {
+ return "passed"
+ }
+ return "skipped_existing"
+ default:
+ if strings.TrimSpace(row.Status) == "" {
+ return "other"
+ }
+ return row.Status
+ }
+}
+
+func failureReason(row resultRow) string {
+ if strings.TrimSpace(row.Error) != "" {
+ return strings.TrimSpace(row.Error)
+ }
+ if row.Status == "passed" && !row.ScenarioPassed {
+ return "scenario_passed=false"
+ }
+ return row.Status
+}
+
+func titleOutcome(outcome string) string {
+ cleaned := strings.ReplaceAll(strings.TrimSpace(outcome), "_", " ")
+ if cleaned == "" {
+ return "Other"
+ }
+ return strings.ToUpper(cleaned[:1]) + cleaned[1:]
+}
+
func gateSummary(gates scenarioGates) string {
if gates.MinRunes == 0 && gates.MinStyleScore == 0 && len(gates.StructuralGateLabels) == 0 {
return ""
@@ -384,7 +804,7 @@ func gateSummary(gates scenarioGates) string {
func failedRows(rows []resultRow) []resultRow {
failures := make([]resultRow, 0)
for _, row := range rows {
- if row.Status == "failed" || (row.Status == "passed" && !row.ScenarioPassed) {
+ if rowOutcome(row) == "failed" {
failures = append(failures, row)
}
}
@@ -422,6 +842,76 @@ func splitCSV(value string) []string {
return values
}
+func joinInts(values []int) string {
+ if len(values) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(values))
+ for _, value := range values {
+ parts = append(parts, strconv.Itoa(value))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func uniqueCaseIDs(rows []resultRow) []string {
+ values := make([]string, 0, len(rows))
+ for _, row := range rows {
+ values = appendIfPresent(values, row.CaseID)
+ }
+ return sortedUnique(values)
+}
+
+func sortedUnique(values []string) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ seen := map[string]bool{}
+ unique := make([]string, 0, len(values))
+ for _, value := range values {
+ cleaned := strings.TrimSpace(value)
+ if cleaned == "" || seen[cleaned] {
+ continue
+ }
+ seen[cleaned] = true
+ unique = append(unique, cleaned)
+ }
+ sort.Strings(unique)
+ return unique
+}
+
+func appendIfPresent(values []string, value string) []string {
+ if strings.TrimSpace(value) == "" {
+ return values
+ }
+ return append(values, strings.TrimSpace(value))
+}
+
+func averageFloat(total float64, count int) float64 {
+ if count == 0 {
+ return 0
+ }
+ return math.Round(total/float64(count)*100) / 100
+}
+
+func averageInt(total int, count int) int {
+ if count == 0 {
+ return 0
+ }
+ return int(math.Round(float64(total) / float64(count)))
+}
+
+func runtimeLabel(baseURLs, models []string) string {
+ baseURL := "n/a"
+ model := "n/a"
+ if len(baseURLs) > 0 {
+ baseURL = strings.Join(baseURLs, ", ")
+ }
+ if len(models) > 0 {
+ model = strings.Join(models, ", ")
+ }
+ return fmt.Sprintf("%s / %s", baseURL, model)
+}
+
func boolValue(value string) bool {
parsed, _ := strconv.ParseBool(value)
return parsed
diff --git a/cmd/scenario/live_media_matrix/main_test.go b/cmd/scenario/live_media_matrix/main_test.go
index 9d39473..23b22d5 100644
--- a/cmd/scenario/live_media_matrix/main_test.go
+++ b/cmd/scenario/live_media_matrix/main_test.go
@@ -40,7 +40,9 @@ func TestPlannedRowReportsActiveGates(t *testing.T) {
func TestDraftGenerationEnvPassesActiveGates(t *testing.T) {
item := matrixCase{
- BriefPath: "tmp/media_matrix/briefs/cloudia_zenn_tutorial.json",
+ BriefPath: "tmp/media_matrix/briefs/cloudia_zenn_tutorial.json",
+ ProfilePath: "tmp/media_matrix/styles/cloudia_zenn_tutorial/profile.json",
+ GuidePath: "tmp/media_matrix/styles/cloudia_zenn_tutorial/guide.json",
ActiveGates: scenarioGates{
MinRunes: 1800,
MinStyleScore: 82,
@@ -51,6 +53,8 @@ func TestDraftGenerationEnvPassesActiveGates(t *testing.T) {
for _, expected := range []string{
"RUN_LOCAL_LLM_SCENARIO=1",
"ARTICLE_BRIEF_PATH=tmp/media_matrix/briefs/cloudia_zenn_tutorial.json",
+ "AUTHOR_PROFILE_PATH=tmp/media_matrix/styles/cloudia_zenn_tutorial/profile.json",
+ "WRITING_GUIDE_PATH=tmp/media_matrix/styles/cloudia_zenn_tutorial/guide.json",
"SCENARIO_OUTPUT_DIR=tmp/media_matrix/live/cloudia_zenn_tutorial",
"SCENARIO_MIN_STYLE_SCORE=82.0",
"SCENARIO_MIN_DRAFT_RUNES=1800",
@@ -61,6 +65,37 @@ func TestDraftGenerationEnvPassesActiveGates(t *testing.T) {
}
}
+func TestApplyStructuralGatesFailsMissingSignals(t *testing.T) {
+ outputDir := t.TempDir()
+ draftPath := filepath.Join(outputDir, "draft.md")
+ if err := os.WriteFile(draftPath, []byte("---\ntitle: ok\n---\n\n## 手順\n\n本文です。\n"), 0o644); err != nil {
+ t.Fatalf("write draft: %v", err)
+ }
+
+ row := resultRow{
+ Status: "passed",
+ ScenarioPassed: true,
+ DraftPath: draftPath,
+ ActiveGates: scenarioGates{
+ StructuralSignals: []string{"---", ":::message", "```"},
+ },
+ }
+ applyStructuralGates(&row)
+
+ if row.ScenarioPassed {
+ t.Fatalf("expected structural gate failure: %+v", row)
+ }
+ if row.Status != "failed" {
+ t.Fatalf("status = %q, want failed", row.Status)
+ }
+ if row.FailureGroup != "structural_gate" {
+ t.Fatalf("failure group = %q", row.FailureGroup)
+ }
+ if !strings.Contains(row.Error, ":::message") || !strings.Contains(row.Error, "```") {
+ t.Fatalf("missing structural signal detail: %q", row.Error)
+ }
+}
+
func TestRunCaseClearsStaleArtifactsWithoutResume(t *testing.T) {
outputDir := t.TempDir()
stalePath := filepath.Join(outputDir, "stderr.txt")
@@ -74,6 +109,9 @@ func TestRunCaseClearsStaleArtifactsWithoutResume(t *testing.T) {
if row.Status != "failed" {
t.Fatalf("status = %q, want failed", row.Status)
}
+ if row.FailureGroup != "runtime_or_runner" {
+ t.Fatalf("failure group = %q, want runtime_or_runner", row.FailureGroup)
+ }
content, err := os.ReadFile(stalePath)
if err != nil {
t.Fatalf("expected fresh stderr artifact: %v", err)
@@ -127,16 +165,21 @@ func TestApplyFailureArtifactsRestoresRuntimeDiagnostics(t *testing.T) {
func TestMarkdownReportShowsGatesBesideActuals(t *testing.T) {
report := aggregateReport{
+ SelectedCaseIDs: []string{"cloudia_qiita_how_to"},
+ RunOrdinals: []int{1},
Rows: []resultRow{
{
- CaseID: "cloudia_qiita_how_to",
- Medium: "Qiita",
- Style: "practical how-to",
- Status: "passed",
- Score: 83.2,
- MinStyleScore: 82,
- Runes: 1510,
- MinRunes: 1400,
+ RunOrdinal: 1,
+ ComparisonKey: "run_01/cloudia_qiita_how_to",
+ CaseID: "cloudia_qiita_how_to",
+ Medium: "Qiita",
+ Style: "practical how-to",
+ Status: "passed",
+ ScenarioPassed: true,
+ Score: 83.2,
+ MinStyleScore: 82,
+ Runes: 1510,
+ MinRunes: 1400,
ActiveGates: scenarioGates{
MinRunes: 1400,
MinStyleScore: 82,
@@ -146,10 +189,13 @@ func TestMarkdownReportShowsGatesBesideActuals(t *testing.T) {
},
},
}
+ report.Summary = summarizeRows(report.Rows, len(report.SelectedCaseIDs))
markdown := markdownReport(report)
for _, expected := range []string{
"Gates",
+ "Selected cases: `cloudia_qiita_how_to`",
+ "Run ordinals: `1`",
"83.2 / 82.0",
"1510 / 1400",
"qiita_long_form",
@@ -160,6 +206,118 @@ func TestMarkdownReportShowsGatesBesideActuals(t *testing.T) {
}
}
+func TestRepeatRunSummaryTracksSelectionOrdinalsAndGroups(t *testing.T) {
+ rows := []resultRow{
+ {
+ RunOrdinal: 1,
+ ComparisonKey: "run_01/cloudia_qiita_how_to",
+ CaseID: "cloudia_qiita_how_to",
+ Status: "passed",
+ ScenarioPassed: true,
+ ElapsedSeconds: 12.25,
+ Score: 84,
+ Runes: 1500,
+ LLMBaseURL: "http://evo-x2.tailb30e58.ts.net/v1",
+ LLMModel: "gemma4:31b",
+ VerifyModel: "gemma4:latest",
+ },
+ {
+ RunOrdinal: 2,
+ ComparisonKey: "run_02/cloudia_qiita_how_to",
+ CaseID: "cloudia_qiita_how_to",
+ Status: "passed",
+ ElapsedSeconds: 20,
+ Score: 79,
+ Runes: 1320,
+ LLMBaseURL: "http://evo-x2.tailb30e58.ts.net/v1",
+ LLMModel: "gemma4:31b",
+ VerifyModel: "gemma4:latest",
+ },
+ {
+ RunOrdinal: 2,
+ ComparisonKey: "run_02/cloudia_zenn_tutorial",
+ CaseID: "cloudia_zenn_tutorial",
+ Status: "failed",
+ Error: "style score below gate",
+ LLMBaseURL: "http://evo-x2.tailb30e58.ts.net/v1",
+ LLMModel: "gemma4:31b",
+ },
+ }
+
+ summary := summarizeRows(rows, 2)
+
+ if summary.TotalRows != 3 || summary.SelectedCases != 2 {
+ t.Fatalf("summary counts = %+v", summary)
+ }
+ if summary.PassedRows != 1 || summary.FailedRows != 2 {
+ t.Fatalf("pass/fail counts = %+v", summary)
+ }
+ if len(summary.ConciseRows) != 2 {
+ t.Fatalf("concise row count = %d, want 2", len(summary.ConciseRows))
+ }
+ if summary.ConciseRows[0].RunOrdinal != 1 || summary.ConciseRows[0].PassedRows != 1 {
+ t.Fatalf("run 1 summary = %+v", summary.ConciseRows[0])
+ }
+ if summary.ConciseRows[1].RunOrdinal != 2 || summary.ConciseRows[1].FailedRows != 2 {
+ t.Fatalf("run 2 summary = %+v", summary.ConciseRows[1])
+ }
+ failedGroup := summary.PassFailGroups[0]
+ if failedGroup.Outcome != "failed" || failedGroup.Count != 2 {
+ t.Fatalf("failed group = %+v", failedGroup)
+ }
+
+ runtime := runtimeFromRows(rows)
+ if !runtime.TailnetEvoX2 || len(runtime.LLMBaseURLs) != 1 || runtime.LLMBaseURLs[0] != "http://evo-x2.tailb30e58.ts.net/v1" {
+ t.Fatalf("runtime metadata = %+v", runtime)
+ }
+}
+
+func TestRepeatRunOutputDirsKeepSingleRunCompatible(t *testing.T) {
+ if got := outputDirForRun("tmp/media_matrix/live", "case_a", 1, 1); got != "tmp/media_matrix/live/case_a" {
+ t.Fatalf("single run output dir = %q", got)
+ }
+ if got := outputDirForRun("tmp/media_matrix/live", "case_a", 2, 1); got != "tmp/media_matrix/live/run_02/case_a" {
+ t.Fatalf("ordinal output dir = %q", got)
+ }
+ if got := outputDirForRun("tmp/media_matrix/live", "case_a", 1, 3); got != "tmp/media_matrix/live/run_01/case_a" {
+ t.Fatalf("repeat output dir = %q", got)
+ }
+}
+
+func TestSelectedCasesPreserveMatrixOrder(t *testing.T) {
+ cases := []matrixCase{{ID: "zenn"}, {ID: "qiita"}, {ID: "note"}}
+ selected := selectedCases(cases, selectedCaseIDs("note,zenn"))
+
+ if got := strings.Join(caseIDs(selected), ","); got != "zenn,note" {
+ t.Fatalf("selected case order = %q", got)
+ }
+}
+
+func TestGeneratedAtIsStableOfflineAndOverridable(t *testing.T) {
+ if got := generatedAt(false); got != offlineGeneratedAt {
+ t.Fatalf("offline generated_at = %q, want %q", got, offlineGeneratedAt)
+ }
+
+ t.Setenv("LIVE_MEDIA_MATRIX_GENERATED_AT", "2026-05-03T00:00:00Z")
+ if got := generatedAt(true); got != "2026-05-03T00:00:00Z" {
+ t.Fatalf("generated_at override = %q", got)
+ }
+}
+
+func TestApplyRunMetricsKeepsRuntimeEnvFallbacks(t *testing.T) {
+ row := resultRow{
+ LLMBaseURL: "http://evo-x2.tailb30e58.ts.net/v1",
+ LLMModel: "gemma4:31b",
+ VerifyModel: "gemma4:latest",
+ }
+
+ applyRunMetrics(&row, map[string]string{"score": "82.5"}, scenarioGates{})
+
+ if row.LLMBaseURL != "http://evo-x2.tailb30e58.ts.net/v1" || row.LLMModel != "gemma4:31b" || row.VerifyModel != "gemma4:latest" {
+ t.Fatalf("runtime fallbacks were overwritten: %+v", row)
+ }
+}
+
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
diff --git a/cmd/scenario/media_matrix/main.go b/cmd/scenario/media_matrix/main.go
index c2a3635..e4bfc52 100644
--- a/cmd/scenario/media_matrix/main.go
+++ b/cmd/scenario/media_matrix/main.go
@@ -6,8 +6,10 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ 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"
@@ -85,6 +87,8 @@ type caseResult struct {
TargetLengthStructure string `json:"target_length_structure"`
SourceSelectors []string `json:"source_selectors"`
BriefPath string `json:"brief_path"`
+ ProfilePath string `json:"profile_path"`
+ GuidePath string `json:"guide_path"`
PromptPath string `json:"prompt_path"`
ActiveGates scenarioGates `json:"active_gates"`
PromptChecks []promptCheckResult `json:"prompt_checks"`
@@ -108,8 +112,9 @@ type promptCheckResult struct {
func main() {
outputDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)
briefDir := filepath.Join(outputDir, "briefs")
+ styleDir := filepath.Join(outputDir, "styles")
promptDir := filepath.Join(outputDir, "prompts")
- for _, dir := range []string{outputDir, briefDir, promptDir} {
+ for _, dir := range []string{outputDir, briefDir, styleDir, promptDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
fatalf("create output dir %s: %v", dir, err)
}
@@ -120,7 +125,6 @@ func main() {
sourceResults := verifyExpectedSources(personas.List())
questionTemplate := fixedQuestionTemplate()
composedTemplates := composedQuestionTemplates()
- guide := scenarioGuide()
results := make([]caseResult, 0, len(plannedCases()))
for _, item := range plannedCases() {
@@ -136,17 +140,29 @@ func main() {
gates := activeGatesForCase(item)
questions := briefdomain.ComposeFixedQuestions(item.PersonaID, item.OutputFormatID)
questionIDs := questionIDsFromQuestions(questions)
+ profile, guide := scenarioStyleAssets(item, persona, format)
- brief := buildBriefFromSession(item)
+ brief := buildBriefFromSession(item, profile.ID)
if brief.PersonaID != persona.ID || brief.OutputFormatID != format.ID {
fatalf("%s assembled mismatched brief persona/format", item.ID)
}
- prompt := draftapp.BuildPromptForMode(guide, brief, persona, format)
+ if brief.StyleProfileID != profile.ID || guide.ProfileID != profile.ID {
+ fatalf("%s assembled mismatched style artifacts", item.ID)
+ }
+ prompt := draftapp.BuildPromptForModeWithProfile(guide, brief, profile, persona, format)
checks := verifyPrompt(item, persona, format, prompt)
briefPath := filepath.Join(briefDir, item.ID+".json")
+ caseStyleDir := filepath.Join(styleDir, item.ID)
+ if err := os.MkdirAll(caseStyleDir, 0o755); err != nil {
+ fatalf("create style dir %s: %v", caseStyleDir, err)
+ }
+ profilePath := filepath.Join(caseStyleDir, "profile.json")
+ guidePath := filepath.Join(caseStyleDir, "guide.json")
promptPath := filepath.Join(promptDir, item.ID+".prompt.md")
writeJSON(briefPath, brief)
+ writeJSON(profilePath, profile)
+ writeJSON(guidePath, guide)
writeFile(promptPath, prompt)
results = append(results, caseResult{
@@ -161,11 +177,13 @@ func main() {
TargetLengthStructure: item.TargetLengthStructure,
SourceSelectors: append([]string(nil), item.SourceSelectors...),
BriefPath: briefPath,
+ ProfilePath: profilePath,
+ GuidePath: guidePath,
PromptPath: promptPath,
ActiveGates: gates,
PromptChecks: checks,
QuestionIDs: append([]string(nil), questionIDs...),
- PlannedLLMCommand: plannedLLMCommand(outputDir, item.ID, briefPath, gates),
+ PlannedLLMCommand: plannedLLMCommand(outputDir, item.ID, briefPath, profilePath, guidePath, gates),
ExpectedMetrics: []string{
"elapsed_seconds",
"score",
@@ -389,8 +407,8 @@ func verifyCaseSources(item matrixCase, sources []sourceSelectorResult) {
}
}
-func buildBriefFromSession(item matrixCase) briefdomain.ArticleBrief {
- session, err := briefdomain.NewArticleBriefSessionWithOptions(item.ID, "profile_media_matrix", item.PersonaID, item.OutputFormatID, "", briefdomain.ComposeFixedQuestions(item.PersonaID, item.OutputFormatID))
+func buildBriefFromSession(item matrixCase, styleProfileID string) briefdomain.ArticleBrief {
+ session, err := briefdomain.NewArticleBriefSessionWithOptions(item.ID, styleProfileID, item.PersonaID, item.OutputFormatID, "", briefdomain.ComposeFixedQuestions(item.PersonaID, item.OutputFormatID))
if err != nil {
fatalf("%s create brief session: %v", item.ID, err)
}
@@ -577,29 +595,83 @@ func activeGatesForCase(item matrixCase) scenarioGates {
}
}
-func plannedLLMCommand(outputDir, caseID, briefPath string, gates scenarioGates) string {
+func plannedLLMCommand(outputDir, caseID, briefPath, profilePath, guidePath string, gates scenarioGates) string {
return fmt.Sprintf(
- "RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_MIN_STYLE_SCORE=%.1f SCENARIO_MIN_DRAFT_RUNES=%d ARTICLE_BRIEF_PATH=%s SCENARIO_OUTPUT_DIR=%s go run ./cmd/scenario/draft_generation",
+ "RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_MIN_STYLE_SCORE=%.1f SCENARIO_MIN_DRAFT_RUNES=%d ARTICLE_BRIEF_PATH=%s AUTHOR_PROFILE_PATH=%s WRITING_GUIDE_PATH=%s SCENARIO_OUTPUT_DIR=%s go run ./cmd/scenario/draft_generation",
gates.MinStyleScore,
gates.MinRunes,
briefPath,
+ profilePath,
+ guidePath,
filepath.Join(outputDir, "live", caseID),
)
}
-func scenarioGuide() authordomain.WritingStyleGuide {
- return authordomain.WritingStyleGuide{
- ID: "guide_media_matrix",
- ProfileID: "profile_media_matrix",
- Markdown: "実体験、検証、読者への次の行動を媒体ごとに配分する。媒体が技術寄りなら手順と根拠を厚くし、noteやホームページでは読者の判断につながる文脈を厚くする。",
- PreferredFirstPerson: "僕",
- RecurringThemes: []string{"AI", "検証", "発信", "実装"},
- ParagraphRhythm: "媒体ごとの読み方に合わせて段落密度を変える。",
- SentenceRhythm: "結論、根拠、次の行動が追える明快な文にする。",
- HeadingGuidance: "読者が期待する粒度で具体的な見出しにする。",
- QuoteGuidance: "引用は主張の転換点に限定する。",
- OpeningPatterns: []string{"具体的な違和感から始める", "検証結果から始める", "読者の作業場面から始める"},
- ConclusionPatterns: []string{"次の行動で締める", "検証の残課題で締める"},
+func scenarioStyleAssets(item matrixCase, persona personadomain.Persona, format outputformat.OutputFormat) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide) {
+ fetchedAt := time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC)
+ content := scenarioStyleCorpus(item, persona, format)
+ source := authordomain.AuthorSource{
+ Username: persona.ID + "_" + format.ID,
+ Articles: []authordomain.SourceArticle{{
+ ID: item.ID + "_style_fixture",
+ URL: "scenario://media_matrix/" + item.ID,
+ Title: item.Theme,
+ At: fetchedAt,
+ }},
+ FetchedAt: fetchedAt,
+ }
+ profile, err := authordomain.BuildAuthorStyleProfile(source, []articledomain.Article{{
+ URL: "scenario://media_matrix/" + item.ID,
+ Title: item.Theme,
+ Content: content,
+ }})
+ if err != nil {
+ fatalf("%s build style profile: %v", item.ID, err)
+ }
+ if len(persona.VoiceNotes.FirstPerson) > 0 {
+ profile.PreferredFirstPerson = persona.VoiceNotes.FirstPerson[0]
+ }
+ guide, err := authordomain.BuildWritingStyleGuide(profile)
+ if err != nil {
+ fatalf("%s build writing guide: %v", item.ID, err)
+ }
+ guide.PreferredFirstPerson = profile.PreferredFirstPerson
+ guide.RecurringThemes = append([]string(nil), profile.RecurringKeywords...)
+ guide.ParagraphRhythm = persona.VoiceNotes.Tone + "\n" + item.ToneStance
+ guide.SentenceRhythm = "媒体に合わせて、短すぎる箇条書きだけで終わらせず、手順・根拠・読者の次の行動を一文ずつ明確にする。"
+ guide.HeadingGuidance = "出力先「" + format.DisplayName + "」で読みやすい粒度の見出しを置く。"
+ guide.OpeningPatterns = []string{item.OpeningEpisode, "読者の困りごとから始めて、テーマへ接続する"}
+ guide.ConclusionPatterns = []string{item.ExpectedReaderAction, "検証結果と次の一歩で締める"}
+ guide.Warnings = append([]string{"scenario_style_fixture"}, persona.VoiceNotes.AntiPatterns...)
+ guide.Markdown = authordomain.GuideMarkdown(guide)
+ if err := guide.Validate(); err != nil {
+ fatalf("%s validate writing guide: %v", item.ID, err)
+ }
+ return profile, guide
+}
+
+func scenarioStyleCorpus(item matrixCase, persona personadomain.Persona, format outputformat.OutputFormat) string {
+ firstPerson := "僕"
+ if len(persona.VoiceNotes.FirstPerson) > 0 {
+ firstPerson = persona.VoiceNotes.FirstPerson[0]
+ }
+ switch persona.ID {
+ case personadomain.IDCloudia:
+ return strings.Repeat(fmt.Sprintf(`## %s
+
+%sは、%sを読者と一緒に小さく試していくばい。%s と %s の違いでつまずくところを先に拾い、コード、検証、結果の順番で楽しく案内するとよ。
+
+%s。%s。%s。クラウディア流!という明るい入口を置きつつ、ZennやQiitaの記法は混ぜず、初心者が自分の手元で再現できるようにするばい。
+
+`, item.Theme, firstPerson, item.Theme, format.DisplayName, item.Medium, item.MustInclude, item.PersonalContext, item.ToneStance), 8)
+ default:
+ return strings.Repeat(fmt.Sprintf(`## %s
+
+%sは、%sについて、実装判断、検証結果、読者が次に取る行動を順番に言語化する。%s。%s。
+
+%s。%s。%s。媒体に合わせて、noteでは違和感と体験を厚くし、会社ブログでは技術知見とビジョン共有を具体的に残す。
+
+`, item.Theme, firstPerson, item.Theme, item.OpeningEpisode, item.MustInclude, item.PersonalContext, item.ExpectedReaderAction, item.ToneStance), 8)
}
}
diff --git a/cmd/scenario/media_matrix/main_test.go b/cmd/scenario/media_matrix/main_test.go
index 01e8893..ff46ffd 100644
--- a/cmd/scenario/media_matrix/main_test.go
+++ b/cmd/scenario/media_matrix/main_test.go
@@ -3,6 +3,9 @@ package main
import (
"strings"
"testing"
+
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
)
func TestActiveGatesForCaseSeparatesHomepageFromLongForm(t *testing.T) {
@@ -58,12 +61,21 @@ func TestActiveGatesForCaseSeparatesHomepageFromLongForm(t *testing.T) {
func TestPlannedLLMCommandIncludesActiveGateEnv(t *testing.T) {
gates := scenarioGates{MinRunes: 1800, MinStyleScore: 82}
- command := plannedLLMCommand("tmp/media_matrix", "case_id", "tmp/media_matrix/briefs/case_id.json", gates)
+ command := plannedLLMCommand(
+ "tmp/media_matrix",
+ "case_id",
+ "tmp/media_matrix/briefs/case_id.json",
+ "tmp/media_matrix/styles/case_id/profile.json",
+ "tmp/media_matrix/styles/case_id/guide.json",
+ gates,
+ )
for _, expected := range []string{
"RUN_LOCAL_LLM_SCENARIO=1",
"SCENARIO_MIN_STYLE_SCORE=82.0",
"SCENARIO_MIN_DRAFT_RUNES=1800",
"ARTICLE_BRIEF_PATH=tmp/media_matrix/briefs/case_id.json",
+ "AUTHOR_PROFILE_PATH=tmp/media_matrix/styles/case_id/profile.json",
+ "WRITING_GUIDE_PATH=tmp/media_matrix/styles/case_id/guide.json",
"SCENARIO_OUTPUT_DIR=tmp/media_matrix/live/case_id",
} {
if !strings.Contains(command, expected) {
@@ -72,6 +84,25 @@ func TestPlannedLLMCommandIncludesActiveGateEnv(t *testing.T) {
}
}
+func TestScenarioStyleAssetsMatchBriefProfile(t *testing.T) {
+ for _, item := range plannedCases() {
+ persona, _ := personadomain.DefaultRegistry().Get(item.PersonaID)
+ format, _ := outputformat.DefaultRegistry().Get(item.OutputFormatID)
+ profile, guide := scenarioStyleAssets(item, persona, format)
+ brief := buildBriefFromSession(item, profile.ID)
+
+ if brief.StyleProfileID != profile.ID {
+ t.Fatalf("%s brief profile = %q, want %q", item.ID, brief.StyleProfileID, profile.ID)
+ }
+ if guide.ProfileID != profile.ID {
+ t.Fatalf("%s guide profile = %q, want %q", item.ID, guide.ProfileID, profile.ID)
+ }
+ if item.PersonaID == "cloudia" && profile.PreferredFirstPerson != "クラウディア" {
+ t.Fatalf("%s preferred first person = %q", item.ID, profile.PreferredFirstPerson)
+ }
+ }
+}
+
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 38bc0fc..dc5db70 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -218,14 +218,14 @@ Current implementation status as of 2026-05-03:
- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
- Style analysis is now persona/format-aware ([#68](https://github.com/terisuke/note_maker/issues/68)): the web UI shows a general `文体ソース` selector instead of `Noteユーザー名`, defaults it to note/Zenn/Qiita/Cor GitHub Markdown based on the selected mode, and makes persona presets include output-format notes. Validation is recorded in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
- The 2026-05-03 full Tailnet Evo X2 media-matrix run proved that the runtime path works but also proved that the current scenario is not sufficient as an interview-template acceptance test: only `terisuke_note_essay` passed, Cor blog failed on assistant preamble leakage, Zenn/Qiita failed on cross-format notation leakage, and homepage failed long-form gates despite being a short HTML section. Runtime stabilization is therefore decomposed under epic [#40](https://github.com/terisuke/note_maker/issues/40) into [#70](https://github.com/terisuke/note_maker/issues/70) template/brief scenario coverage, [#71](https://github.com/terisuke/note_maker/issues/71) failed draft artifacts, [#72](https://github.com/terisuke/note_maker/issues/72) bounded format repair, [#73](https://github.com/terisuke/note_maker/issues/73) output-format-specific gates, and [#74](https://github.com/terisuke/note_maker/issues/74) staged Evo X2 reruns.
+- The #70-#73 prerequisite slice is now in place for #74: interview-template coverage exists, failed draft artifacts are preserved, format-only failures can be repaired once without relaxing validators, and scenario gates are split by output format. The first staged #74 Tailnet rerun used `cloudia_zenn_tutorial` and moved past the original failure class but failed strict style (`73.6 / 82.0`). The current cut fixes the reliability gap behind that score by generating case-specific style profile/guide artifacts, passing them into live runs, rejecting profile/guide/brief mismatches, making final verification block `scenario_passed`, enforcing structural signals, and exposing UI `quality_gate` details. The bounded rerun now passes the Zenn proof: `cloudia_zenn_tutorial` scored `88.1 / 82.0`, generated `5040 / 1800` runes, passed lightweight verification, and used the Tailnet endpoint `http://evo-x2.tailb30e58.ts.net/v1`. Validation is recorded in [Issue 74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md).
Near-term execution order:
-1. Add an interview-template scenario ([#70](https://github.com/terisuke/note_maker/issues/70)) before spending more Evo X2 runtime. It must prove that the simplified questions are small, medium-specific, and able to produce distinct `ArticleBrief` outputs for note, Cor blog, Zenn, Qiita, and homepage.
-2. Make failed generation diagnosable ([#71](https://github.com/terisuke/note_maker/issues/71)) and recoverable when the issue is format-only ([#72](https://github.com/terisuke/note_maker/issues/72)).
-3. Split scenario gates by output format ([#73](https://github.com/terisuke/note_maker/issues/73)) so homepage HTML is not judged as a long article while note/Zenn/Qiita/Cor blog remain strict.
-4. Re-run Evo X2 in stages ([#74](https://github.com/terisuke/note_maker/issues/74)): template scenario, offline media matrix, one previously failing live case, then the full note/Qiita/Zenn/Cor blog comparison.
-5. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel where write scopes do not conflict.
+1. Run one adjacent Cloudia technical case (`cloudia_qiita_how_to`) to check that the Cloudia/Zenn calibration and case-specific style artifacts do not leak Zenn-only notation into Qiita.
+2. If Qiita passes, run the full note/Qiita/Zenn/Cor blog comparison under [#74](https://github.com/terisuke/note_maker/issues/74), excluding homepage from the #40 closure gate while keeping it as a separate format check.
+3. Close [#40](https://github.com/terisuke/note_maker/issues/40) only after the full live matrix records primary/fallback endpoint, per-phase models, elapsed time, runes, style score, final verification, structural signals, `quality_gate`, and artifact paths for each publishing target, with no runtime, format-validation, final-verification, strict style-gate, or structural-signal failures.
+4. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work, not prerequisites for closing #40.
## Tracked issues
@@ -245,7 +245,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
- 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. Sub-issues: [#70](https://github.com/terisuke/note_maker/issues/70), [#71](https://github.com/terisuke/note_maker/issues/71), [#72](https://github.com/terisuke/note_maker/issues/72), [#73](https://github.com/terisuke/note_maker/issues/73), [#74](https://github.com/terisuke/note_maker/issues/74).
+- 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 proof; the remaining #74 work is the adjacent Cloudia/Qiita proof and then the full publishing-target matrix.
## Consequences
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 8afd75e..ecebb4d 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -23,6 +23,7 @@ Implemented and merged:
- [#29](https://github.com/terisuke/note_maker/issues/29) — focused handler tests for the expanded `workflow.go` surface; `go test ./internal/handlers -cover` now reaches 80.0%.
- [#57](https://github.com/terisuke/note_maker/issues/57) — live media-matrix runner and aggregate JSON/Markdown evaluator with offline planned mode by default.
- [#61](https://github.com/terisuke/note_maker/issues/61) / [PR #62](https://github.com/terisuke/note_maker/pull/62) — workflow storage mode can be inspected and switched from the settings UI; environment-locked deployments remain read-only.
+- [#70](https://github.com/terisuke/note_maker/issues/70)-[#73](https://github.com/terisuke/note_maker/issues/73) prerequisite slice for runtime evaluation — interview-template coverage, failed draft artifact preservation, bounded format repair, and output-format-specific scenario gates are in place for staged #74 reruns.
Open and active:
@@ -30,7 +31,7 @@ Open and active:
- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
-- Runtime evaluation sub-issues: [#70](https://github.com/terisuke/note_maker/issues/70), [#71](https://github.com/terisuke/note_maker/issues/71), [#72](https://github.com/terisuke/note_maker/issues/72), [#73](https://github.com/terisuke/note_maker/issues/73), [#74](https://github.com/terisuke/note_maker/issues/74).
+- Runtime evaluation sub-issue still blocking #40: [#74](https://github.com/terisuke/note_maker/issues/74), now narrowed to Cloudia/Zenn style calibration plus staged live reruns.
- 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).
- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
@@ -61,43 +62,55 @@ Each live run must record:
- final verification result,
- output path and scenario case id.
-## Before the full Evo X2 media run
+## Current #74 status
-The previous prerequisites are in place, but the 2026-05-03 live result exposed a missing layer in the validation plan. A draft-only media matrix cannot prove that the revised question templates are usable, because it starts from completed `ArticleBrief` fixtures.
+The #70-#73 prerequisites are in place. The first staged #74 Tailnet rerun intentionally targeted one previously failing medium, `cloudia_zenn_tutorial`, and isolated the remaining failure:
-The runtime stabilization work is now split under epic #40:
+| Layer | Result |
+|---|---|
+| Tailnet runtime | passed: Evo X2 OpenAI-compatible endpoint responded and streamed metrics |
+| Format validation | passed: generated Markdown was accepted as Zenn output |
+| Length gate | passed: `3905` runes against `1800` minimum |
+| Final verification | passed with `gemma4:latest` |
+| Strict style gate | failed: `73.6 / 82.0` |
-| Order | Issue | Purpose | Done when |
-|---:|---|---|---|
-| 1 | [#70](https://github.com/terisuke/note_maker/issues/70) | Add an interview-template scenario | note/Cor blog/Zenn/Qiita/homepage questions and generated briefs differ by mode and remain small enough to answer |
-| 2 | [#71](https://github.com/terisuke/note_maker/issues/71) | Preserve failed draft artifacts | unusable drafts still write raw output, failure JSON, elapsed time, endpoint, and model |
-| 3 | [#72](https://github.com/terisuke/note_maker/issues/72) | Add bounded format repair | preamble leakage and Zenn/Qiita notation leakage get one strict repair retry without relaxing validators |
-| 4 | [#73](https://github.com/terisuke/note_maker/issues/73) | Split scenario gates by output format | homepage uses short HTML gates while long-form media keep strict length/style gates |
-| 5 | [#74](https://github.com/terisuke/note_maker/issues/74) | Re-run staged Evo X2 validation | one previously failing medium passes first, then the full note/Qiita/Zenn/Cor blog live matrix is rerun |
+The current cut fixed the evaluation reliability gap before trusting that score:
+
+- `cmd/scenario/media_matrix` now emits case-specific style `profile.json` and `guide.json`.
+- `cmd/scenario/live_media_matrix` passes those artifacts into `cmd/scenario/draft_generation`.
+- Draft generation rejects profile/guide/brief style-profile mismatches.
+- Final verification failure now blocks `scenario_passed`.
+- Structural signals are enforced, not only reported.
+- The web response includes `quality_gate` details so failed scores keep the draft visible.
+
+The bounded Zenn proof now passes:
+
+| Case | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
+|---|---:|---:|---:|---:|---:|---|
+| `cloudia_zenn_tutorial` | `741.93` | `86456ms` | `2205` | `88.1 / 82.0` | `5040 / 1800` | passed |
+
+The remaining #74 work is not another Zenn-only calibration pass. It is the adjacent `cloudia_qiita_how_to` proof and then the full publishing-target matrix.
## Parallel implementation plan
-Use subagents with disjoint write scopes:
+Use subagents with disjoint write scopes when implementation resumes:
| Lane | Issue | Subagent role | Write scope | Done when |
|---|---|---|---|---|
-| A | [#70](https://github.com/terisuke/note_maker/issues/70) | Template scenario worker | `cmd/scenario/*`, `internal/domain/brief/*`, validation docs | question-template usability is measured before draft-only live runs |
-| B | [#71](https://github.com/terisuke/note_maker/issues/71) / [#72](https://github.com/terisuke/note_maker/issues/72) | Draft recovery worker | `internal/application/draft/*`, `internal/domain/article/*`, scenario output paths | failed drafts are diagnosable and recoverable format errors get one repair attempt |
-| C | [#73](https://github.com/terisuke/note_maker/issues/73) | Scenario gate worker | `cmd/scenario/*`, validation docs | long-form and homepage gates are explicit and recorded |
+| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Adjacent Cloudia/Qiita proof worker | `cmd/scenario/*`, validation docs, no evaluator weakening | `cloudia_qiita_how_to` passes without Zenn notation leakage |
+| B | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
-Lanes A, B, and C can run in parallel if their write scopes stay separate. Lane D/E can continue in parallel when they do not need the same frontend files.
+Lane A should land before Lane B spends full live Evo X2 time. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Implement #70 first. This proves the revised questions and generated briefs before any more expensive live draft runs.
-2. Implement #71/#72/#73 in parallel where possible. These directly address the failures observed on 2026-05-03.
-3. Run one bounded Evo X2 live case from a previously failing medium, not the already-passing note case.
-4. Start or continue #27/#28 so expensive live outputs can be viewed and reused from the web app.
-5. Run the full note/Qiita/Zenn/company-blog matrix under #74, then update #40 with the aggregate.
-6. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
+1. Run `cloudia_qiita_how_to` as the adjacent Cloudia technical proof. It should keep the improved Cloudia voice while preserving Qiita-specific notation.
+2. Run the full note/Qiita/Zenn/company-blog matrix under #74 and update #40 with the aggregate JSON/Markdown plus artifact paths.
+3. Close #40 only when every publishing target records endpoint, phase models, elapsed time, runes, style score, structural signals, final verification, `quality_gate`, and artifacts with no runtime, format-validation, final-verification, structural-signal, or strict style-gate failures. Homepage can remain a separate format check and is not part of the #40 closure gate.
+4. Continue #27/#28 and #13 in parallel as product-readiness work. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
## Why not run the full Evo X2 matrix now?
-The source and prompt matrix is ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The 2026-05-03 full run also showed that draft-only evaluation can miss whether interview templates are actually usable. The better sequence is to prove the question-to-brief layer first, preserve failed outputs, repair recoverable format mistakes, then use #40/#74 to evaluate one varied failing slice before the full comparison table.
+The source, prompt, artifact, repair, and gate layers are ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The Zenn proof now passes, so the next useful spend is the adjacent Qiita case to catch cross-format notation regression before the full #40 run.
diff --git a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
index 631f72c..54cc120 100644
--- a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
+++ b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
@@ -51,4 +51,74 @@ This is a useful staged failure:
- Lightweight final verification passed.
- The remaining failure is strict style score: `73.6`, below the Zenn gate `82.0`.
-Issue #74 should remain open. The next implementation work should tune Cloudia/Zenn style scoring or prompt calibration before the full note/Qiita/Zenn/Cor blog live matrix is rerun.
+Issue #74 should remain open. The blocker is now specifically **Cloudia/Zenn style calibration**, not runtime, format, artifact capture, repair, length, or final verification.
+
+The next implementation work should use the preserved draft and evaluation artifacts to tune Cloudia/Zenn style guidance or scoring calibration before spending a full Evo X2 matrix run. The calibration must keep the Zenn validator strict: no assistant preamble, valid Zenn frontmatter, Zenn-only notation where applicable, and no Qiita notation leakage.
+
+## Calibration rerun
+
+The current cut added the missing reliability layer before rerunning the same Zenn case:
+
+- each media-matrix case now has its own style `profile.json` and `guide.json`,
+- live media-matrix runs pass those case-specific artifacts to `draft_generation`,
+- draft generation rejects profile/guide/brief style-profile mismatches,
+- final verification failure now blocks `scenario_passed`,
+- structural signals are enforced by the live aggregate runner,
+- the web response exposes `quality_gate` details so low-score drafts remain visible.
+
+Command:
+
+```sh
+LIVE_MEDIA_MATRIX_CASES=cloudia_zenn_tutorial make scenario-media-matrix-live
+```
+
+Result:
+
+| Case | Status | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
+|---|---|---:|---:|---:|---:|---:|---|
+| `cloudia_zenn_tutorial` | passed | `741.93` | `86456ms` | `2205` | `88.1 / 82.0` | `5040 / 1800` | passed |
+
+Runtime:
+
+- Endpoint: `http://evo-x2.tailb30e58.ts.net/v1`
+- Draft model: `gemma4:31b`
+- Verify model: `gemma4:latest`
+- Transport: Tailscale VPN / OpenAI-compatible API
+
+Artifacts:
+
+- Aggregate: `tmp/media_matrix/live/aggregate.json`
+- Report: `tmp/media_matrix/live/aggregate.md`
+- Draft: `tmp/media_matrix/live/cloudia_zenn_tutorial/draft.md`
+- Evaluation: `tmp/media_matrix/live/cloudia_zenn_tutorial/evaluation.json`
+- Verification: `tmp/media_matrix/live/cloudia_zenn_tutorial/verification.json`
+- Style profile: `tmp/media_matrix/styles/cloudia_zenn_tutorial/profile.json`
+- Style guide: `tmp/media_matrix/styles/cloudia_zenn_tutorial/guide.json`
+
+Interpretation:
+
+- The Tailnet path still works.
+- The Zenn format validator accepted the article.
+- Required Zenn structural signals were present.
+- The rune gate passed.
+- Lightweight final verification passed.
+- The strict style gate now passes: `88.1`, above the Zenn gate `82.0`.
+
+## Next proof sequence
+
+1. Run `cloudia_qiita_how_to` next to verify that the Cloudia voice improvement does not introduce Zenn-specific notation into Qiita.
+2. If both Cloudia technical cases pass, run the full live publishing-target matrix for note, Qiita, Zenn, and Cor blog.
+
+## #40 closure condition
+
+Issue #40 can close only after the full live matrix records endpoint, per-phase models, elapsed seconds, generated runes, style score, final verification result, and artifact paths for every publishing target, with:
+
+- no runtime endpoint failure,
+- no output-format validation failure,
+- no final-verification failure,
+- no strict style-gate failure,
+- no structural-signal failure,
+- no style profile/guide/brief mismatch,
+- no missing failed-or-successful draft artifacts.
+
+The homepage section case can remain as a separate format check, but it is not part of the #40 publishing-target closure gate.
diff --git a/internal/application/draft/evaluation.go b/internal/application/draft/evaluation.go
index 32e35c8..9f9bd5a 100644
--- a/internal/application/draft/evaluation.go
+++ b/internal/application/draft/evaluation.go
@@ -5,11 +5,18 @@ import (
"strings"
articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
)
// EvaluateStyle compares a validated draft against the author's style profile.
func EvaluateStyle(profile AuthorStyleProfile, brief ArticleBrief, articleDraft articledomain.Draft) StyleEvaluation {
- candidate := articledomain.AnalyzeStyle(articleDraft.Markdown())
+ cloudiaStyle := isCloudiaStyleEvaluation(profile, brief)
+ styleMarkdown := articleDraft.Markdown()
+ if cloudiaStyle {
+ styleMarkdown = styleEvaluationMarkdown(styleMarkdown, brief.OutputFormatID)
+ }
+ candidate := articledomain.AnalyzeStyle(styleMarkdown)
comparison := articledomain.CompareStyle(profile.Metrics, candidate)
evaluation := StyleEvaluation{
@@ -28,10 +35,11 @@ func EvaluateStyle(profile AuthorStyleProfile, brief ArticleBrief, articleDraft
evaluation.checkMetric("quote_density", StrictThresholds.QuoteDensity)
evaluation.checkMetric("first_person", StrictThresholds.FirstPerson)
- if evaluation.RequiredFirstPerson != "" && !strings.Contains(articleDraft.Markdown(), evaluation.RequiredFirstPerson) {
+ if evaluation.RequiredFirstPerson != "" && !strings.Contains(styleMarkdown, evaluation.RequiredFirstPerson) {
evaluation.Passed = false
evaluation.Failures = append(evaluation.Failures, fmt.Sprintf("preferred_first_person=%q missing", evaluation.RequiredFirstPerson))
}
+ evaluation.checkCloudiaPersonaSignal(cloudiaStyle, styleMarkdown)
if len(evaluation.Failures) > 0 {
evaluation.Passed = false
@@ -57,9 +65,98 @@ func (e *StyleEvaluation) addFailure(metric string, value, threshold float64) {
e.Failures = append(e.Failures, fmt.Sprintf("%s=%.1f below %.1f", metric, value, threshold))
}
+func (e *StyleEvaluation) checkCloudiaPersonaSignal(cloudiaStyle bool, styleMarkdown string) {
+ if !cloudiaStyle {
+ return
+ }
+ if containsAny(styleMarkdown, []string{"クラウディア", "うち", "ばい", "とよ", "やけん", "なんしよっと"}) {
+ return
+ }
+ e.Passed = false
+ e.Failures = append(e.Failures, "cloudia_first_person_signal missing from article body")
+}
+
+func isCloudiaStyleEvaluation(profile AuthorStyleProfile, brief ArticleBrief) bool {
+ if personadomain.NormalizeID(brief.PersonaID) != personadomain.IDCloudia {
+ return false
+ }
+ profileHints := []string{profile.ID, profile.Source.Username, profile.PreferredFirstPerson}
+ for _, article := range profile.Source.Articles {
+ profileHints = append(profileHints, article.ID, article.URL, article.Title)
+ }
+ return containsAny(strings.ToLower(strings.Join(profileHints, "\n")), []string{"cloudia", "クラウディア", "うち"})
+}
+
func requiredFirstPerson(profile AuthorStyleProfile, brief ArticleBrief) string {
if override := explicitFirstPersonOverride(brief); override != "" {
return override
}
return normalizedFirstPerson(profile.PreferredFirstPerson)
}
+
+func styleEvaluationMarkdown(markdown, formatID string) string {
+ markdown = strings.ReplaceAll(markdown, "\r\n", "\n")
+ markdown = strings.ReplaceAll(markdown, "\r", "\n")
+ markdown = stripYAMLFrontmatter(markdown)
+ markdown = stripCodeFences(markdown)
+ if outputformat.NormalizeID(formatID) == outputformat.IDZennArticle {
+ markdown = stripZennBoilerplate(markdown)
+ }
+ return strings.TrimSpace(markdown)
+}
+
+func stripYAMLFrontmatter(markdown string) string {
+ if !strings.HasPrefix(strings.TrimSpace(markdown), "---\n") {
+ return markdown
+ }
+ text := strings.TrimSpace(markdown)
+ if end := strings.Index(text[4:], "\n---"); end >= 0 {
+ rest := text[4+end+len("\n---"):]
+ return strings.TrimLeft(rest, "\n")
+ }
+ return markdown
+}
+
+func stripCodeFences(markdown string) string {
+ lines := strings.Split(markdown, "\n")
+ cleaned := make([]string, 0, len(lines))
+ inFence := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
+ inFence = !inFence
+ continue
+ }
+ if inFence {
+ continue
+ }
+ cleaned = append(cleaned, line)
+ }
+ return strings.Join(cleaned, "\n")
+}
+
+func stripZennBoilerplate(markdown string) string {
+ lines := strings.Split(markdown, "\n")
+ cleaned := make([]string, 0, len(lines))
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ switch {
+ case strings.HasPrefix(trimmed, ":::"):
+ continue
+ case strings.HasPrefix(trimmed, "@[card]("), strings.HasPrefix(trimmed, "@[gist]("):
+ continue
+ default:
+ cleaned = append(cleaned, line)
+ }
+ }
+ return strings.Join(cleaned, "\n")
+}
+
+func containsAny(text string, values []string) bool {
+ for _, value := range values {
+ if strings.Contains(text, value) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/application/draft/evaluation_test.go b/internal/application/draft/evaluation_test.go
new file mode 100644
index 0000000..8a3a3d8
--- /dev/null
+++ b/internal/application/draft/evaluation_test.go
@@ -0,0 +1,119 @@
+package draft
+
+import (
+ "strings"
+ "testing"
+
+ articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
+)
+
+func TestEvaluateStyleIgnoresZennBoilerplateForStyleMetrics(t *testing.T) {
+ body := cloudiaTechnicalBody("クラウディアは", "ばい")
+ profile := AuthorStyleProfile{
+ ID: "profile-cloudia",
+ Metrics: articledomain.AnalyzeStyle(body),
+ PreferredFirstPerson: "クラウディア",
+ }
+ raw := zennArticleWithBody(body) + "\n\n" +
+ "```ts:src/main.ts\n" +
+ "const persona = 'クラウディア';\n" +
+ "console.log('AIの違和感を言語化するコード例');\n" +
+ "```\n\n" +
+ "@[card](https://example.com/cloudia-reference)"
+ articleDraft, err := articledomain.NewDraftForFormat(raw, outputformat.IDZennArticle)
+ if err != nil {
+ t.Fatalf("new zenn draft: %v", err)
+ }
+
+ evaluation := EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ }, articleDraft)
+
+ if !evaluation.Passed {
+ t.Fatalf("expected evaluation to pass with Zenn boilerplate ignored: %#v", evaluation)
+ }
+ expected := articledomain.AnalyzeStyle(body)
+ if evaluation.Comparison.Candidate.ParagraphCount != expected.ParagraphCount {
+ t.Fatalf("candidate paragraphs = %d, want body-only %d", evaluation.Comparison.Candidate.ParagraphCount, expected.ParagraphCount)
+ }
+ if evaluation.Comparison.Candidate.KeywordCounts["AI"] != expected.KeywordCounts["AI"] {
+ t.Fatalf("candidate AI count = %d, want body-only %d", evaluation.Comparison.Candidate.KeywordCounts["AI"], expected.KeywordCounts["AI"])
+ }
+}
+
+func TestEvaluateStyleRequiresCloudiaSignalInArticleBodyNotFrontmatter(t *testing.T) {
+ profile := AuthorStyleProfile{
+ ID: "profile-cloudia",
+ Metrics: articledomain.AnalyzeStyle(cloudiaTechnicalBody("クラウディアは", "ばい")),
+ PreferredFirstPerson: "明示しない",
+ }
+ neutralBody := cloudiaTechnicalBody("この記事では", "です")
+ frontmatterOnlyDraft, err := articledomain.NewDraftForFormat(
+ zennArticleWithBody(neutralBody),
+ outputformat.IDZennArticle,
+ )
+ if err != nil {
+ t.Fatalf("new zenn draft: %v", err)
+ }
+
+ evaluation := EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ }, frontmatterOnlyDraft)
+
+ if evaluation.Passed {
+ t.Fatalf("expected Cloudia signal failure when only frontmatter names Cloudia: %#v", evaluation)
+ }
+ if !failureContains(evaluation.Failures, "cloudia_first_person_signal missing from article body") {
+ t.Fatalf("expected actionable Cloudia signal failure: %#v", evaluation.Failures)
+ }
+
+ bodySignalDraft, err := articledomain.NewDraftForFormat(
+ zennArticleWithBody(strings.Replace(neutralBody, "この記事では", "うちは", 1)),
+ outputformat.IDZennArticle,
+ )
+ if err != nil {
+ t.Fatalf("new zenn draft with body signal: %v", err)
+ }
+ evaluation = EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ }, bodySignalDraft)
+
+ if !evaluation.Passed {
+ t.Fatalf("expected body Cloudia signal to satisfy persona check: %#v", evaluation)
+ }
+}
+
+func cloudiaTechnicalBody(subject, ending string) string {
+ intro := "## はじめに\n\n"
+ intro += strings.Repeat(subject+"AIの実装で違和感を言語化しながら、自分の手元で小さく検証する"+ending+"。音楽の練習みたいに、まずログを見て挑戦の入口をそろえる"+ending+"。\n\n", 6)
+ steps := "## 手順\n\n"
+ steps += strings.Repeat(subject+"エンジニア向けに、アウトプットを急がず仮説、コード、結果を順番に見せる"+ending+"。起業の現場でも救いになる判断基準を残す"+ending+"。\n\n", 6)
+ wrap := "## まとめ\n\n"
+ wrap += strings.Repeat(subject+"最後にAIの結果を鵜呑みにせず、違和感をメモして次の自分に渡す"+ending+"。\n\n", 4)
+ return intro + steps + wrap
+}
+
+func zennArticleWithBody(body string) string {
+ return "---\n" +
+ "title: \"クラウディア流!AI探検記\"\n" +
+ "emoji: \"🛸\"\n" +
+ "type: \"tech\"\n" +
+ "topics: [\"ai\", \"go\", \"zenn\"]\n" +
+ "published: false\n" +
+ "---\n\n" +
+ body
+}
+
+func failureContains(failures []string, want string) bool {
+ for _, failure := range failures {
+ if strings.Contains(failure, want) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/application/draft/section.go b/internal/application/draft/section.go
index 2f601aa..aea551c 100644
--- a/internal/application/draft/section.go
+++ b/internal/application/draft/section.go
@@ -127,7 +127,7 @@ func (s *Service) RegenerateSection(ctx context.Context, req RegenerateSectionRe
var ok bool
format, ok = outputformat.DefaultRegistry().Get(req.Brief.OutputFormatID)
if !ok {
- format, _ = outputformat.DefaultRegistry().Get(outputformat.IDNoteArticle)
+ return RegenerateSectionResult{}, fmt.Errorf("unknown output format %q", req.Brief.OutputFormatID)
}
}
prompt := BuildSectionRegenerationPrompt(req.StyleGuide, req.Brief, req.AuthorProfile, persona, format, req.DraftMarkdown, section)
diff --git a/internal/application/draft/service.go b/internal/application/draft/service.go
index 5774b52..1d9f9de 100644
--- a/internal/application/draft/service.go
+++ b/internal/application/draft/service.go
@@ -107,7 +107,7 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
var ok bool
format, ok = outputformat.DefaultRegistry().Get(req.Brief.OutputFormatID)
if !ok {
- format, _ = outputformat.DefaultRegistry().Get(outputformat.IDNoteArticle)
+ return GenerateResult{}, fmt.Errorf("unknown output format %q", req.Brief.OutputFormatID)
}
}
@@ -134,7 +134,11 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
}
repairedRaw, repairErr := s.generator.Generate(ctx, BuildFormatRepairPrompt(format, rawDraft, err))
if repairErr != nil {
- return GenerateResult{}, fmt.Errorf("repair draft format with local llm: %w", repairErr)
+ return GenerateResult{}, &UnusableDraftError{
+ FormatID: format.ID,
+ Attempts: attempts,
+ Err: fmt.Errorf("repair draft format with local llm: %w", repairErr),
+ }
}
articleDraft, repairErr = articledomain.NewDraftForFormat(repairedRaw, format.ID)
attempts = appendGenerationAttempt(attempts, "format_repair", repairedRaw, repairErr)
@@ -287,5 +291,11 @@ func validateRequest(req GenerateRequest) error {
if err := req.AuthorProfile.Validate(); err != nil {
return fmt.Errorf("author style profile is invalid: %w", err)
}
+ if strings.TrimSpace(req.StyleGuide.ProfileID) != strings.TrimSpace(req.AuthorProfile.ID) {
+ return fmt.Errorf("writing style guide profile id %q does not match author profile id %q", req.StyleGuide.ProfileID, req.AuthorProfile.ID)
+ }
+ if strings.TrimSpace(req.Brief.StyleProfileID) != "" && strings.TrimSpace(req.Brief.StyleProfileID) != strings.TrimSpace(req.AuthorProfile.ID) {
+ return fmt.Errorf("article brief style profile id %q does not match author profile id %q", req.Brief.StyleProfileID, req.AuthorProfile.ID)
+ }
return nil
}
diff --git a/internal/application/draft/service_test.go b/internal/application/draft/service_test.go
index fd15d4b..1645a24 100644
--- a/internal/application/draft/service_test.go
+++ b/internal/application/draft/service_test.go
@@ -457,6 +457,85 @@ func TestGenerateRejectsUnusableMarkdown(t *testing.T) {
}
}
+func TestGenerateRejectsMismatchedStyleArtifacts(t *testing.T) {
+ profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
+ styleGuide.ProfileID = "other_profile"
+
+ _, err := NewService(&fakeGenerator{draft: matchingDraft()}).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{StyleProfileID: profile.ID, Theme: "不一致を検出する"},
+ AuthorProfile: profile,
+ })
+ if err == nil || !strings.Contains(err.Error(), "does not match author profile id") {
+ t.Fatalf("expected guide/profile mismatch error, got %v", err)
+ }
+
+ profile, styleGuide = profileAndGuideFromDraft(t, matchingDraft())
+ _, err = NewService(&fakeGenerator{draft: matchingDraft()}).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{StyleProfileID: "wrong_profile", Theme: "不一致を検出する"},
+ AuthorProfile: profile,
+ })
+ if err == nil || !strings.Contains(err.Error(), "article brief style profile id") {
+ t.Fatalf("expected brief/profile mismatch error, got %v", err)
+ }
+}
+
+func TestGenerateRejectsUnknownBriefOutputFormat(t *testing.T) {
+ profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
+
+ _, err := NewService(&fakeGenerator{draft: matchingDraft()}).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{StyleProfileID: profile.ID, Theme: "未知形式", OutputFormatID: "missing_format"},
+ AuthorProfile: profile,
+ })
+ if err == nil || !strings.Contains(err.Error(), `unknown output format "missing_format"`) {
+ t.Fatalf("expected unknown output format error, got %v", err)
+ }
+}
+
+func TestGeneratePreservesInitialAttemptWhenRepairTransportFails(t *testing.T) {
+ invalidZenn := "---\n" +
+ "title: \"Goで検証する\"\n" +
+ "emoji: \"🧪\"\n" +
+ "type: \"tech\"\n" +
+ "topics: [\"go\", \"test\"]\n" +
+ "published: false\n" +
+ "---\n\n" +
+ "## 実装\n\n" +
+ ":::note info\nQiitaの補足です\n:::\n"
+ profile, styleGuide := profileAndGuideFromDraft(t, strings.ReplaceAll(invalidZenn, ":::note info", ":::message"))
+ persona, _ := personadomain.DefaultRegistry().Get(personadomain.IDCloudia)
+ format, _ := outputformat.DefaultRegistry().Get(outputformat.IDZennArticle)
+ generator := &sequenceGenerator{drafts: []string{invalidZenn}, errAtCall: 2, err: errors.New("repair endpoint unavailable")}
+
+ _, err := NewService(generator).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{
+ StyleProfileID: profile.ID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Theme: "Goで検証する",
+ },
+ AuthorProfile: profile,
+ Persona: persona,
+ OutputFormat: format,
+ })
+ if err == nil {
+ t.Fatal("expected repair transport error")
+ }
+ var unusable *UnusableDraftError
+ if !errors.As(err, &unusable) {
+ t.Fatalf("expected UnusableDraftError, got %T: %v", err, err)
+ }
+ if len(unusable.Attempts) != 1 || unusable.Attempts[0].RawOutput != invalidZenn {
+ t.Fatalf("initial raw attempt was not preserved: %#v", unusable.Attempts)
+ }
+ if !strings.Contains(unusable.Error(), "repair endpoint unavailable") {
+ t.Fatalf("repair error not surfaced: %v", unusable)
+ }
+}
+
func TestEvaluateStylePassesStrictThresholdsAndOverride(t *testing.T) {
text := strings.ReplaceAll(matchingDraft(), "僕", "私")
articleDraft, err := articledomain.NewDraft(text)
@@ -492,13 +571,19 @@ func (g *fakeGenerator) Generate(ctx context.Context, prompt string) (string, er
}
type sequenceGenerator struct {
- prompts []string
- drafts []string
- calls int
+ prompts []string
+ drafts []string
+ calls int
+ errAtCall int
+ err error
}
func (g *sequenceGenerator) Generate(ctx context.Context, prompt string) (string, error) {
g.prompts = append(g.prompts, prompt)
+ if g.errAtCall > 0 && g.calls+1 == g.errAtCall {
+ g.calls++
+ return "", g.err
+ }
if g.calls >= len(g.drafts) {
g.calls++
return g.drafts[len(g.drafts)-1], nil
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index 0240604..a99f51c 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -152,8 +152,56 @@ type regenerateDraftSectionRequest struct {
type generateDraftResponse struct {
Draft string `json:"draft"`
+ DraftPath string `json:"draft_path,omitempty"`
Evaluation draftapp.StyleEvaluation `json:"evaluation"`
Verification draftapp.FinalVerification `json:"verification"`
+ QualityGate draftQualityGateDetails `json:"quality_gate"`
+}
+
+type draftQualityGateDetails struct {
+ Passed bool `json:"passed"`
+ Score float64 `json:"score"`
+ FailedScore *float64 `json:"failed_score,omitempty"`
+ Runes int `json:"runes"`
+ Failures []string `json:"failures,omitempty"`
+ FailedMetrics []draftFailedMetric `json:"failed_metrics,omitempty"`
+ Verification draftVerificationStatus `json:"verification"`
+ Draft draftArtifactDetails `json:"draft"`
+}
+
+type draftFailedMetric struct {
+ Name string `json:"name"`
+ Score *float64 `json:"score,omitempty"`
+ Threshold *float64 `json:"threshold,omitempty"`
+ Missing bool `json:"missing,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+type draftVerificationStatus struct {
+ Performed bool `json:"performed"`
+ Passed bool `json:"passed"`
+ Status string `json:"status"`
+ Summary string `json:"summary,omitempty"`
+ Failures []string `json:"failures,omitempty"`
+}
+
+type draftArtifactDetails struct {
+ Text string `json:"text,omitempty"`
+ Path string `json:"path,omitempty"`
+ Runes int `json:"runes"`
+}
+
+type draftGenerationErrorResponse struct {
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Details string `json:"details,omitempty"`
+ } `json:"error"`
+ Draft string `json:"draft,omitempty"`
+ DraftPath string `json:"draft_path,omitempty"`
+ Evaluation draftapp.StyleEvaluation `json:"evaluation,omitempty"`
+ Verification draftapp.FinalVerification `json:"verification,omitempty"`
+ QualityGate *draftQualityGateDetails `json:"quality_gate,omitempty"`
}
type regenerateDraftSectionResponse struct {
@@ -162,6 +210,124 @@ type regenerateDraftSectionResponse struct {
UpdatedDraftMarkdown string `json:"updated_draft_markdown"`
}
+func toGenerateDraftResponse(result draftapp.GenerateResult, draftPath string) generateDraftResponse {
+ markdown := result.Draft.Markdown()
+ return generateDraftResponse{
+ Draft: markdown,
+ DraftPath: draftPath,
+ Evaluation: result.Evaluation,
+ Verification: result.Verification,
+ QualityGate: buildDraftQualityGateDetails(markdown, draftPath, result.Evaluation, result.Verification),
+ }
+}
+
+func buildDraftQualityGateDetails(markdown, draftPath string, evaluation draftapp.StyleEvaluation, verification draftapp.FinalVerification) draftQualityGateDetails {
+ score := evaluation.Comparison.Score
+ var failedScore *float64
+ if !evaluation.Passed {
+ failedScore = &score
+ }
+ return draftQualityGateDetails{
+ Passed: evaluation.Passed && (!verification.Performed || verification.Passed),
+ Score: score,
+ FailedScore: failedScore,
+ Runes: len([]rune(markdown)),
+ Failures: append([]string(nil), evaluation.Failures...),
+ FailedMetrics: failedMetricsFromEvaluation(evaluation),
+ Verification: verificationStatus(verification),
+ Draft: draftArtifactDetails{
+ Text: markdown,
+ Path: draftPath,
+ Runes: len([]rune(markdown)),
+ },
+ }
+}
+
+func failedMetricsFromEvaluation(evaluation draftapp.StyleEvaluation) []draftFailedMetric {
+ if evaluation.Passed && len(evaluation.Failures) == 0 {
+ return nil
+ }
+ metrics := make([]draftFailedMetric, 0, len(evaluation.Failures))
+ if evaluation.Comparison.Score < evaluation.Thresholds.TotalScore {
+ metrics = append(metrics, failedMetric("total_style_score", evaluation.Comparison.Score, evaluation.Thresholds.TotalScore, ""))
+ }
+ for _, threshold := range []struct {
+ name string
+ value int
+ }{
+ {"paragraph_length", evaluation.Thresholds.ParagraphLength},
+ {"sentence_length", evaluation.Thresholds.SentenceLength},
+ {"keyword_overlap", evaluation.Thresholds.KeywordOverlap},
+ {"quote_density", evaluation.Thresholds.QuoteDensity},
+ {"first_person", evaluation.Thresholds.FirstPerson},
+ } {
+ value, ok := evaluation.Comparison.MetricScores[threshold.name]
+ if !ok {
+ thresholdValue := float64(threshold.value)
+ metrics = append(metrics, draftFailedMetric{Name: threshold.name, Threshold: &thresholdValue, Missing: true, Message: threshold.name + " missing"})
+ continue
+ }
+ if value < threshold.value {
+ metrics = append(metrics, failedMetric(threshold.name, float64(value), float64(threshold.value), ""))
+ }
+ }
+ for _, failure := range evaluation.Failures {
+ if strings.Contains(failure, "preferred_first_person") {
+ metrics = append(metrics, draftFailedMetric{Name: "preferred_first_person", Message: failure})
+ }
+ }
+ return metrics
+}
+
+func failedMetric(name string, score, threshold float64, message string) draftFailedMetric {
+ if message == "" {
+ message = fmt.Sprintf("%s=%.1f below %.1f", name, score, threshold)
+ }
+ return draftFailedMetric{
+ Name: name,
+ Score: &score,
+ Threshold: &threshold,
+ Message: message,
+ }
+}
+
+func verificationStatus(verification draftapp.FinalVerification) draftVerificationStatus {
+ status := "not_run"
+ if verification.Performed {
+ status = "failed"
+ if verification.Passed {
+ status = "passed"
+ }
+ }
+ return draftVerificationStatus{
+ Performed: verification.Performed,
+ Passed: verification.Passed,
+ Status: status,
+ Summary: verification.Summary,
+ Failures: append([]string(nil), verification.Failures...),
+ }
+}
+
+func draftGenerationErrorPayload(result draftapp.GenerateResult, err error, code, message, draftPath string) (draftGenerationErrorResponse, bool) {
+ var response draftGenerationErrorResponse
+ markdown := result.Draft.Markdown()
+ if strings.TrimSpace(markdown) == "" && len(result.Evaluation.Failures) == 0 && result.Evaluation.Comparison.Score == 0 && !result.Verification.Performed {
+ return response, false
+ }
+ response.Error.Code = code
+ response.Error.Message = message
+ if err != nil {
+ response.Error.Details = err.Error()
+ }
+ response.Draft = markdown
+ response.DraftPath = draftPath
+ response.Evaluation = result.Evaluation
+ response.Verification = result.Verification
+ qualityGate := buildDraftQualityGateDetails(markdown, draftPath, result.Evaluation, result.Verification)
+ response.QualityGate = &qualityGate
+ return response, true
+}
+
// ListPersonasHandler returns built-in writing personas.
func ListPersonasHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, personadomain.DefaultRegistry().List())
@@ -711,14 +877,14 @@ func GenerateDraftHandler(w http.ResponseWriter, r *http.Request) {
OutputFormat: format,
})
if err != nil {
+ if response, ok := draftGenerationErrorPayload(result, err, "DRAFT_GENERATION_FAILED", "Failed to generate draft", ""); ok {
+ respondWithJSON(w, http.StatusUnprocessableEntity, response)
+ return
+ }
respondWithError(w, "DRAFT_GENERATION_FAILED", "Failed to generate draft", err.Error(), http.StatusInternalServerError)
return
}
- respondWithJSON(w, http.StatusOK, generateDraftResponse{
- Draft: result.Draft.Markdown(),
- Evaluation: result.Evaluation,
- Verification: result.Verification,
- })
+ respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, ""))
}
func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDraftRequest, profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) {
@@ -757,14 +923,24 @@ func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDra
},
})
if err != nil {
+ if response, ok := draftGenerationErrorPayload(result, err, "DRAFT_GENERATION_FAILED", "Failed to generate draft", ""); ok {
+ _ = stream.Send("error", streamError{
+ Code: response.Error.Code,
+ Message: response.Error.Message,
+ Detail: response.Error.Details,
+ ElapsedMS: stream.ElapsedMS(),
+ Draft: response.Draft,
+ DraftPath: response.DraftPath,
+ Evaluation: &response.Evaluation,
+ Verification: &response.Verification,
+ QualityGate: response.QualityGate,
+ })
+ return
+ }
_ = stream.Send("error", streamError{Code: "DRAFT_GENERATION_FAILED", Message: "Failed to generate draft", Detail: err.Error(), ElapsedMS: stream.ElapsedMS()})
return
}
- _ = stream.Send("result", generateDraftResponse{
- Draft: result.Draft.Markdown(),
- Evaluation: result.Evaluation,
- Verification: result.Verification,
- })
+ _ = stream.Send("result", toGenerateDraftResponse(result, ""))
_ = stream.Send("done", streamStatus{Status: "completed", Phase: "draft", Endpoint: endpoint, Model: model, StartedAt: stream.started.Format(time.RFC3339), ElapsedMS: stream.ElapsedMS(), Runes: len([]rune(result.Draft.Markdown())), Score: result.Evaluation.Comparison.Score})
}
@@ -893,10 +1069,15 @@ type streamChunk struct {
}
type streamError struct {
- Code string `json:"code"`
- Message string `json:"message"`
- Detail string `json:"detail,omitempty"`
- ElapsedMS int64 `json:"elapsed_ms"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Detail string `json:"detail,omitempty"`
+ ElapsedMS int64 `json:"elapsed_ms"`
+ Draft string `json:"draft,omitempty"`
+ DraftPath string `json:"draft_path,omitempty"`
+ Evaluation *draftapp.StyleEvaluation `json:"evaluation,omitempty"`
+ Verification *draftapp.FinalVerification `json:"verification,omitempty"`
+ QualityGate *draftQualityGateDetails `json:"quality_gate,omitempty"`
}
func newSSEStream(w http.ResponseWriter) (*sseStream, bool) {
diff --git a/internal/handlers/workflow_stream_test.go b/internal/handlers/workflow_stream_test.go
index ce94e15..4488f09 100644
--- a/internal/handlers/workflow_stream_test.go
+++ b/internal/handlers/workflow_stream_test.go
@@ -214,6 +214,145 @@ func TestGenerateDraftHandlerReturnsJSONDraft(t *testing.T) {
}
}
+func TestGenerateDraftHandlerReturnsQualityGateDetailsForFailedJSONDraft(t *testing.T) {
+ const failedDraft = "# Draft\n\nこれは短い説明です。"
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ if payload.Stream {
+ t.Fatal("JSON draft path should not request streaming")
+ }
+ switch payload.Model {
+ case "draft-failed-json-test":
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":` + quoteJSONString(failedDraft) + `}}]}`))
+ case "verify-failed-json-test":
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"NEEDS REVIEW\nSummary: ブリーフの具体性が不足しています\n- MustInclude is under-covered"}}]}`))
+ default:
+ t.Fatalf("unexpected model: %s", payload.Model)
+ }
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("DRAFT_LLM_MODEL", "draft-failed-json-test")
+ t.Setenv("VERIFY_LLM_MODEL", "verify-failed-json-test")
+
+ style := setupWorkflowStyle(t)
+ if err := workflowStore.SaveBrief("session_failed_json_draft", briefdomain.ArticleBrief{
+ StyleProfileID: style.Profile.ID,
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ Theme: "失敗詳細を返す",
+ Reader: "UIを保守する開発者",
+ MustInclude: "品質ゲート、評価詳細、下書き本文",
+ TargetLengthStructure: "2500字前後",
+ }); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session_failed_json_draft","draft_model":"draft-failed-json-test","verify_model":"verify-failed-json-test"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBufferString(body))
+ request.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+
+ GenerateDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload generateDraftResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.Draft != failedDraft || payload.QualityGate.Draft.Text != failedDraft {
+ t.Fatalf("failed draft was not preserved: %#v", payload.QualityGate.Draft)
+ }
+ if payload.QualityGate.Passed || payload.QualityGate.FailedScore == nil {
+ t.Fatalf("expected failed quality gate with score: %#v", payload.QualityGate)
+ }
+ if payload.QualityGate.Runes != len([]rune(failedDraft)) {
+ t.Fatalf("runes = %d, want %d", payload.QualityGate.Runes, len([]rune(failedDraft)))
+ }
+ if len(payload.QualityGate.FailedMetrics) == 0 || len(payload.QualityGate.Failures) == 0 {
+ t.Fatalf("expected failed metrics and failures: %#v", payload.QualityGate)
+ }
+ if payload.QualityGate.Verification.Status != "failed" {
+ t.Fatalf("verification status = %q, want failed", payload.QualityGate.Verification.Status)
+ }
+}
+
+func TestGenerateDraftHandlerStreamsQualityGateDetailsForFailedDraft(t *testing.T) {
+ const failedDraft = "# Draft\n\nこれは短い説明です。"
+ llmServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ var payload struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode llm request: %v", err)
+ }
+ switch payload.Model {
+ case "draft-failed-stream-test":
+ if !payload.Stream {
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":` + quoteJSONString(failedDraft) + `}}]}`))
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":` + quoteJSONString(failedDraft) + `}}]}` + "\n\n"))
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ case "verify-failed-stream-test":
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"NEEDS REVIEW\nSummary: ブリーフの具体性が不足しています\n- MustInclude is under-covered"}}]}`))
+ default:
+ t.Fatalf("unexpected model: %s", payload.Model)
+ }
+ }))
+ defer llmServer.Close()
+ t.Setenv("LLM_BASE_URL", llmServer.URL+"/v1")
+ t.Setenv("DRAFT_LLM_MODEL", "draft-failed-stream-test")
+ t.Setenv("VERIFY_LLM_MODEL", "verify-failed-stream-test")
+
+ style := setupWorkflowStyle(t)
+ if err := workflowStore.SaveBrief("session_failed_stream_draft", briefdomain.ArticleBrief{
+ StyleProfileID: style.Profile.ID,
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ Theme: "失敗詳細をストリームする",
+ Reader: "UIを保守する開発者",
+ MustInclude: "品質ゲート、評価詳細、下書き本文",
+ TargetLengthStructure: "2500字前後",
+ }); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+
+ body := `{"style_profile_id":"` + style.Profile.ID + `","session_id":"session_failed_stream_draft","draft_model":"draft-failed-stream-test","verify_model":"verify-failed-stream-test"}`
+ request := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBufferString(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "text/event-stream")
+ response := httptest.NewRecorder()
+
+ GenerateDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ stream := response.Body.String()
+ for _, want := range []string{"event: result", `"quality_gate"`, `"failed_score"`, `"failed_metrics"`, `"verification"`, "# Draft"} {
+ if !strings.Contains(stream, want) {
+ t.Fatalf("stream missing %q:\n%s", want, stream)
+ }
+ }
+}
+
func quoteJSONString(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `"`, `\"`)
diff --git a/static/js/script.js b/static/js/script.js
index 627fc9a..bc7e6e3 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -423,7 +423,13 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
if (event === 'error') {
- throw new Error(data.message || data.detail || 'stream error');
+ if (data.quality_gate) {
+ renderDraft(data);
+ el.draftStatus.textContent = '品質ゲートで停止しました。生成済みMarkdownと評価詳細を残しています。';
+ }
+ const error = new Error(data.message || data.detail || 'stream error');
+ error.payload = data;
+ throw error;
}
if (event === 'done') {
el.draftStatus.textContent = draftDoneText(data);
@@ -433,6 +439,8 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (error) {
if (error.name === 'AbortError') {
el.draftStatus.textContent = '停止しました。途中まで生成されたMarkdownは残しています。';
+ } else if (error.payload?.quality_gate) {
+ showError(`下書き生成は品質ゲートで停止しました: ${error.message}`);
} else {
showError(`下書き生成に失敗しました: ${error.message}`);
}
@@ -1141,25 +1149,48 @@ document.addEventListener('DOMContentLoaded', () => {
}
function renderDraft(data) {
+ const qualityGate = data.quality_gate || data.qualityGate || {};
const evaluation = data.evaluation;
const passed = evaluation?.Passed ?? evaluation?.passed;
const comparison = evaluation?.Comparison ?? evaluation?.comparison;
const failures = evaluation?.Failures ?? evaluation?.failures ?? [];
- const score = comparison?.score ?? comparison?.Score ?? 0;
+ const score = qualityGate.score ?? comparison?.score ?? comparison?.Score ?? 0;
+ const runes = qualityGate.runes ?? qualityGate.draft?.runes ?? 0;
+ const failedMetrics = qualityGate.failed_metrics || qualityGate.failedMetrics || [];
+ const draftText = data.draft ?? qualityGate.draft?.text ?? qualityGate.draft_text ?? '';
el.evaluationSummary.className = `evaluation ${passed ? 'passed' : 'failed'}`;
el.evaluationSummary.innerHTML = `
${passed ? 'PASS' : 'NEEDS REVIEW'}
style score: ${Number(score).toFixed(1)}
+ ${runes ? `${Number(runes).toLocaleString()}字 ` : ''}
+ ${failedMetrics.length ? `${failedMetrics.map(failedMetricSummary).join(' ')}
` : ''}
${failures.length ? `${failures.join(' ')}
` : ''}
`;
- renderVerification(data.verification);
- el.markdownOutput.value = data.draft;
+ renderVerification(data.verification || qualityGate.verification);
+ el.markdownOutput.value = draftText;
syncDraftEditor();
el.draftResult.classList.remove('hidden');
setActiveTab('preview');
}
+ function failedMetricSummary(metric) {
+ const name = escapeHTML(metric.name || metric.Name || 'metric');
+ const score = metric.score ?? metric.Score;
+ const threshold = metric.threshold ?? metric.Threshold;
+ const message = metric.message || metric.Message || '';
+ if (message) {
+ return escapeHTML(message);
+ }
+ if (metric.missing || metric.Missing) {
+ return `${name} missing`;
+ }
+ if (score !== undefined && threshold !== undefined) {
+ return `${name}: ${Number(score).toFixed(1)} / ${Number(threshold).toFixed(1)}`;
+ }
+ return name;
+ }
+
function syncDraftEditor() {
el.previewContent.innerHTML = marked.parse(el.markdownOutput.value || '');
updateSectionControls();
From 9c5697435ee371dd639ef635760e7b286a2473b3 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 14:31:18 +0900
Subject: [PATCH 26/33] Document Cloudia Qiita Evo X2 proof (#79)
---
...02-multi-persona-multi-format-extension.md | 11 ++--
.../next-implementation-cut.md | 19 +++----
...issue-74-staged-evo-x2-rerun-2026-05-03.md | 56 ++++++++++++++++++-
3 files changed, 68 insertions(+), 18 deletions(-)
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index dc5db70..747d5a7 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -218,14 +218,13 @@ Current implementation status as of 2026-05-03:
- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
- Style analysis is now persona/format-aware ([#68](https://github.com/terisuke/note_maker/issues/68)): the web UI shows a general `文体ソース` selector instead of `Noteユーザー名`, defaults it to note/Zenn/Qiita/Cor GitHub Markdown based on the selected mode, and makes persona presets include output-format notes. Validation is recorded in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
- The 2026-05-03 full Tailnet Evo X2 media-matrix run proved that the runtime path works but also proved that the current scenario is not sufficient as an interview-template acceptance test: only `terisuke_note_essay` passed, Cor blog failed on assistant preamble leakage, Zenn/Qiita failed on cross-format notation leakage, and homepage failed long-form gates despite being a short HTML section. Runtime stabilization is therefore decomposed under epic [#40](https://github.com/terisuke/note_maker/issues/40) into [#70](https://github.com/terisuke/note_maker/issues/70) template/brief scenario coverage, [#71](https://github.com/terisuke/note_maker/issues/71) failed draft artifacts, [#72](https://github.com/terisuke/note_maker/issues/72) bounded format repair, [#73](https://github.com/terisuke/note_maker/issues/73) output-format-specific gates, and [#74](https://github.com/terisuke/note_maker/issues/74) staged Evo X2 reruns.
-- The #70-#73 prerequisite slice is now in place for #74: interview-template coverage exists, failed draft artifacts are preserved, format-only failures can be repaired once without relaxing validators, and scenario gates are split by output format. The first staged #74 Tailnet rerun used `cloudia_zenn_tutorial` and moved past the original failure class but failed strict style (`73.6 / 82.0`). The current cut fixes the reliability gap behind that score by generating case-specific style profile/guide artifacts, passing them into live runs, rejecting profile/guide/brief mismatches, making final verification block `scenario_passed`, enforcing structural signals, and exposing UI `quality_gate` details. The bounded rerun now passes the Zenn proof: `cloudia_zenn_tutorial` scored `88.1 / 82.0`, generated `5040 / 1800` runes, passed lightweight verification, and used the Tailnet endpoint `http://evo-x2.tailb30e58.ts.net/v1`. Validation is recorded in [Issue 74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md).
+- The #70-#73 prerequisite slice is now in place for #74: interview-template coverage exists, failed draft artifacts are preserved, format-only failures can be repaired once without relaxing validators, and scenario gates are split by output format. The first staged #74 Tailnet rerun used `cloudia_zenn_tutorial` and moved past the original failure class but failed strict style (`73.6 / 82.0`). The current cut fixes the reliability gap behind that score by generating case-specific style profile/guide artifacts, passing them into live runs, rejecting profile/guide/brief mismatches, making final verification block `scenario_passed`, enforcing structural signals, and exposing UI `quality_gate` details. The bounded reruns now pass both Cloudia technical proofs: `cloudia_zenn_tutorial` scored `88.1 / 82.0` with `5040 / 1800` runes, and `cloudia_qiita_how_to` scored `83.7 / 82.0` with `3318 / 1400` runes. Both used the Tailnet endpoint `http://evo-x2.tailb30e58.ts.net/v1` and passed lightweight verification. Validation is recorded in [Issue 74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md).
Near-term execution order:
-1. Run one adjacent Cloudia technical case (`cloudia_qiita_how_to`) to check that the Cloudia/Zenn calibration and case-specific style artifacts do not leak Zenn-only notation into Qiita.
-2. If Qiita passes, run the full note/Qiita/Zenn/Cor blog comparison under [#74](https://github.com/terisuke/note_maker/issues/74), excluding homepage from the #40 closure gate while keeping it as a separate format check.
-3. Close [#40](https://github.com/terisuke/note_maker/issues/40) only after the full live matrix records primary/fallback endpoint, per-phase models, elapsed time, runes, style score, final verification, structural signals, `quality_gate`, and artifact paths for each publishing target, with no runtime, format-validation, final-verification, strict style-gate, or structural-signal failures.
-4. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work, not prerequisites for closing #40.
+1. Run the full note/Qiita/Zenn/Cor blog comparison under [#74](https://github.com/terisuke/note_maker/issues/74), excluding homepage from the #40 closure gate while keeping it as a separate format check.
+2. Close [#40](https://github.com/terisuke/note_maker/issues/40) only after the full live matrix records primary/fallback endpoint, per-phase models, elapsed time, runes, style score, final verification, structural signals, `quality_gate`, and artifact paths for each publishing target, with no runtime, format-validation, final-verification, strict style-gate, or structural-signal failures.
+3. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work, not prerequisites for closing #40.
## Tracked issues
@@ -245,7 +244,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
- 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 proof; the remaining #74 work is the adjacent Cloudia/Qiita proof and then the full publishing-target matrix.
+- 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; the remaining #74 work is the full publishing-target matrix.
## Consequences
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index ecebb4d..2be7972 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -83,13 +83,14 @@ The current cut fixed the evaluation reliability gap before trusting that score:
- Structural signals are enforced, not only reported.
- The web response includes `quality_gate` details so failed scores keep the draft visible.
-The bounded Zenn proof now passes:
+The bounded Cloudia technical proofs now pass:
| Case | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
|---|---:|---:|---:|---:|---:|---|
| `cloudia_zenn_tutorial` | `741.93` | `86456ms` | `2205` | `88.1 / 82.0` | `5040 / 1800` | passed |
+| `cloudia_qiita_how_to` | `598.62` | `111645ms` | `1336` | `83.7 / 82.0` | `3318 / 1400` | passed |
-The remaining #74 work is not another Zenn-only calibration pass. It is the adjacent `cloudia_qiita_how_to` proof and then the full publishing-target matrix.
+The remaining #74 work is the full publishing-target matrix.
## Parallel implementation plan
@@ -97,20 +98,18 @@ 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) | Adjacent Cloudia/Qiita proof worker | `cmd/scenario/*`, validation docs, no evaluator weakening | `cloudia_qiita_how_to` passes without Zenn notation leakage |
-| B | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
+| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
-Lane A should land before Lane B spends full live Evo X2 time. Lane D/E can continue in parallel when they do not need the same frontend files.
+Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Run `cloudia_qiita_how_to` as the adjacent Cloudia technical proof. It should keep the improved Cloudia voice while preserving Qiita-specific notation.
-2. Run the full note/Qiita/Zenn/company-blog matrix under #74 and update #40 with the aggregate JSON/Markdown plus artifact paths.
-3. Close #40 only when every publishing target records endpoint, phase models, elapsed time, runes, style score, structural signals, final verification, `quality_gate`, and artifacts with no runtime, format-validation, final-verification, structural-signal, or strict style-gate failures. Homepage can remain a separate format check and is not part of the #40 closure gate.
-4. Continue #27/#28 and #13 in parallel as product-readiness work. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
+1. Run the full note/Qiita/Zenn/company-blog matrix under #74 and update #40 with the aggregate JSON/Markdown plus artifact paths.
+2. Close #40 only when every publishing target records endpoint, phase models, elapsed time, runes, style score, structural signals, final verification, `quality_gate`, and artifacts with no runtime, format-validation, final-verification, structural-signal, or strict style-gate failures. Homepage can remain a separate format check and is not part of the #40 closure gate.
+3. Continue #27/#28 and #13 in parallel as product-readiness work. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
## Why not run the full Evo X2 matrix now?
-The source, prompt, artifact, repair, and gate layers are ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The Zenn proof now passes, so the next useful spend is the adjacent Qiita case to catch cross-format notation regression before the full #40 run.
+The source, prompt, artifact, repair, and gate layers are ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The Zenn and Qiita bounded proofs now pass, so the next useful spend is the full #40 publishing-target run.
diff --git a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
index 54cc120..4afdc12 100644
--- a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
+++ b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
@@ -106,8 +106,60 @@ Interpretation:
## Next proof sequence
-1. Run `cloudia_qiita_how_to` next to verify that the Cloudia voice improvement does not introduce Zenn-specific notation into Qiita.
-2. If both Cloudia technical cases pass, run the full live publishing-target matrix for note, Qiita, Zenn, and Cor blog.
+1. Run the full live publishing-target matrix for note, Qiita, Zenn, and Cor blog.
+
+## Adjacent Qiita proof
+
+After the Zenn proof passed, the next bounded run checked the adjacent Cloudia technical target: Qiita.
+
+Purpose:
+
+- Verify that the Cloudia style calibration generalizes beyond Zenn.
+- Confirm that Qiita-specific format and structural gates remain strict.
+- Catch cross-format regression, especially Zenn-only notation leaking into Qiita as an actual block.
+
+Command:
+
+```sh
+LIVE_MEDIA_MATRIX_CASES=cloudia_qiita_how_to make scenario-media-matrix-live
+```
+
+Runtime:
+
+- Endpoint: `http://evo-x2.tailb30e58.ts.net/v1`
+- Draft model: `gemma4:31b`
+- Verify model: `gemma4:latest`
+- Transport: Tailscale VPN / OpenAI-compatible API
+
+Result:
+
+| Case | Status | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
+|---|---|---:|---:|---:|---:|---:|---|
+| `cloudia_zenn_tutorial` | passed | `741.93` | `86456ms` | `2205` | `88.1 / 82.0` | `5040 / 1800` | passed |
+| `cloudia_qiita_how_to` | passed | `598.62` | `111645ms` | `1336` | `83.7 / 82.0` | `3318 / 1400` | passed |
+
+Artifacts:
+
+- Aggregate: `tmp/media_matrix/live/aggregate.json`
+- Report: `tmp/media_matrix/live/aggregate.md`
+- Draft: `tmp/media_matrix/live/cloudia_qiita_how_to/draft.md`
+- Evaluation: `tmp/media_matrix/live/cloudia_qiita_how_to/evaluation.json`
+- Verification: `tmp/media_matrix/live/cloudia_qiita_how_to/verification.json`
+- Style profile: `tmp/media_matrix/styles/cloudia_qiita_how_to/profile.json`
+- Style guide: `tmp/media_matrix/styles/cloudia_qiita_how_to/guide.json`
+
+Interpretation:
+
+- The Tailnet path still works for the adjacent Cloudia technical case.
+- The Qiita format validator accepted the article.
+- Required Qiita structural signals were present: frontmatter `title:`, `:::note`, `diff_` code fence, and `## ` headings.
+- The draft used Qiita's `diff_go` style rather than Zenn's `diff go` style.
+- Zenn `:::message` appeared only as explanatory inline/table text, not as an actual block.
+- The rune gate passed.
+- Lightweight final verification passed.
+- The strict style gate passed: `83.7`, above the Qiita gate `82.0`.
+
+The two bounded Cloudia technical proofs now both pass. The next useful Evo X2 spend is the full publishing-target matrix.
## #40 closure condition
From 08f77874c98b84edad0ec90f7e28ad0658ff6d21 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 17:18:32 +0900
Subject: [PATCH 27/33] Stabilize full Evo X2 publishing matrix
Closes #74
Closes #40
---
Makefile | 6 +-
cmd/scenario/draft_generation/main.go | 150 ++++++++++++--
cmd/scenario/draft_generation/main_test.go | 122 ++++++++++++
cmd/scenario/live_media_matrix/main.go | 88 ++++++++-
cmd/scenario/live_media_matrix/main_test.go | 77 ++++++++
cmd/scenario/media_matrix/main.go | 9 +-
cmd/scenario/media_matrix/main_test.go | 24 +++
...02-multi-persona-multi-format-extension.md | 10 +-
.../issue-adr-guardrails.md | 11 +-
.../multi-persona-multi-format.md | 6 +-
.../next-implementation-cut.md | 30 +--
.../issue-40-epic-decomposition-2026-05-03.md | 41 +++-
...issue-74-staged-evo-x2-rerun-2026-05-03.md | 65 +++++-
...matrix-integrated-evaluation-2026-05-03.md | 44 +++++
internal/application/draft/evaluation.go | 5 +-
internal/application/draft/evaluation_test.go | 126 ++++++++++++
.../draft/format_guides/markdown_blog.md | 2 +
.../application/draft/format_guides/qiita.md | 2 +
.../application/draft/format_guides/zenn.md | 2 +
internal/application/draft/prompt.go | 2 +-
internal/application/draft/service.go | 133 ++++++++++++-
internal/application/draft/service_test.go | 186 +++++++++++++++++-
internal/application/draft/verification.go | 22 ++-
.../application/draft/verification_test.go | 60 +++++-
internal/domain/article/draft.go | 48 +++++
internal/domain/article/draft_test.go | 98 ++++++++-
internal/domain/article/style_profile.go | 47 ++++-
internal/domain/article/style_profile_test.go | 104 +++++++++-
internal/infrastructure/llamacpp/client.go | 160 +++++++++++++--
.../infrastructure/llamacpp/client_test.go | 71 +++++++
30 files changed, 1646 insertions(+), 105 deletions(-)
diff --git a/Makefile b/Makefile
index 311e250..6aa69cd 100644
--- a/Makefile
+++ b/Makefile
@@ -30,6 +30,8 @@ BRIEF_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),qwen3:30b-a3b
ARTICLE_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),gemma4:e2b
DRAFT_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),qwen3:30b-a3b
VERIFY_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),gemma4:e2b
+LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS ?= 45
+LLM_STREAM_IDLE_TIMEOUT_SECONDS ?= 45
LLAMACPP_HOST ?= 127.0.0.1
LLAMACPP_PORT ?= 8081
LLAMACPP_BASE_URL ?= $(LLM_BASE_URL)
@@ -61,11 +63,11 @@ evo-x2-ssh-models:
curl -s "$(EVO_X2_SSH_LLM_BASE_URL)/models"
scenario-evo-x2: evo-x2-preflight
- RUN_NOTE_SCENARIO=1 RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_STREAM_DRAFT=1 LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=2800 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/full_workflow
+ RUN_NOTE_SCENARIO=1 RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_STREAM_DRAFT=1 LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS="$(LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS)" LLM_STREAM_IDLE_TIMEOUT_SECONDS="$(LLM_STREAM_IDLE_TIMEOUT_SECONDS)" LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=2800 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/full_workflow
scenario-media-matrix-live: evo-x2-preflight
SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
- RUN_LIVE_MEDIA_MATRIX=1 SCENARIO_STREAM_DRAFT=1 SCENARIO_OUTPUT_DIR=tmp/media_matrix LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=1400 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/live_media_matrix
+ RUN_LIVE_MEDIA_MATRIX=1 SCENARIO_STREAM_DRAFT=1 SCENARIO_OUTPUT_DIR=tmp/media_matrix LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS="$(LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS)" LLM_STREAM_IDLE_TIMEOUT_SECONDS="$(LLM_STREAM_IDLE_TIMEOUT_SECONDS)" LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=1400 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/live_media_matrix
server:
go run ./cmd/server
diff --git a/cmd/scenario/draft_generation/main.go b/cmd/scenario/draft_generation/main.go
index 33ff4e8..5ce5a71 100644
--- a/cmd/scenario/draft_generation/main.go
+++ b/cmd/scenario/draft_generation/main.go
@@ -69,16 +69,16 @@ func main() {
service := draftapp.NewServiceWithVerifier(client, draftapp.NewLightweightVerifier(verifyClient))
var result draftapp.GenerateResult
- var finalElapsed time.Duration
- var finalFirstChunk time.Duration
- var finalChunks int
- finalAttempt := 0
+ var bestAttempt scenarioAttemptResult
+ var currentAttempt int
+ retryFeedback := ""
for attempt := 1; attempt <= maxAttempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
started := time.Now()
+ attemptBrief := briefWithScenarioRetryFeedback(brief, retryFeedback)
request := draftapp.GenerateRequest{
StyleGuide: guide,
- Brief: brief,
+ Brief: attemptBrief,
AuthorProfile: profile,
}
chunkCount := 0
@@ -111,28 +111,44 @@ func main() {
failurePath := writeFailureAttempt(outputDir, attempt, err, metrics, failureContext, artifacts)
fatalf("generate draft attempt %d: %v (failure=%s)", attempt, err, failurePath)
}
- finalElapsed = elapsed
- finalFirstChunk = firstChunk
- finalChunks = chunkCount
- finalAttempt = attempt
+ currentAttempt = attempt
writeRawAttemptArtifacts(outputDir, attempt, result.Attempts)
writeFile(filepath.Join(outputDir, fmt.Sprintf("draft_attempt_%d.md", attempt)), result.Draft.Markdown()+"\n")
writeJSON(filepath.Join(outputDir, fmt.Sprintf("evaluation_attempt_%d.json", attempt)), result.Evaluation)
writeJSON(filepath.Join(outputDir, fmt.Sprintf("verification_attempt_%d.json", attempt)), result.Verification)
- if result.Evaluation.Comparison.Score >= minStyleScore && len([]rune(result.Draft.Markdown())) >= minDraftRunes && verificationGatePassed(result.Verification) {
+ runes := len([]rune(result.Draft.Markdown()))
+ candidate := scenarioAttemptResult{
+ Attempt: attempt,
+ Result: result,
+ Runes: runes,
+ Elapsed: elapsed,
+ FirstChunk: firstChunk,
+ StreamingChunks: chunkCount,
+ }
+ if betterScenarioAttempt(candidate, bestAttempt, minDraftRunes, minStyleScore) {
+ bestAttempt = candidate
+ }
+ if scenarioAttemptPassed(result, runes, minDraftRunes, minStyleScore) {
break
}
+ retryFeedback = scenarioRetryFeedback(result, runes, minDraftRunes, minStyleScore)
+ }
+ if bestAttempt.Attempt == 0 {
+ fatalf("no draft generation attempts completed")
}
+ result = bestAttempt.Result
writeFile(filepath.Join(outputDir, "draft.md"), result.Draft.Markdown()+"\n")
writeJSON(filepath.Join(outputDir, "evaluation.json"), result.Evaluation)
writeJSON(filepath.Join(outputDir, "verification.json"), result.Verification)
- runes := len([]rune(result.Draft.Markdown()))
- passesScenario := result.Evaluation.Comparison.Score >= minStyleScore && runes >= minDraftRunes && verificationGatePassed(result.Verification)
+ runes := bestAttempt.Runes
+ passesScenario := scenarioAttemptPassed(result, runes, minDraftRunes, minStyleScore)
fmt.Printf("draft generation scenario completed\n")
fmt.Printf("scenario_passed=%v\n", passesScenario)
- fmt.Printf("attempt=%d\n", finalAttempt)
+ fmt.Printf("attempt=%d\n", bestAttempt.Attempt)
+ fmt.Printf("selected_attempt=%d\n", bestAttempt.Attempt)
+ fmt.Printf("current_attempt=%d\n", currentAttempt)
fmt.Printf("passed=%v\n", result.Evaluation.Passed)
fmt.Printf("score=%.1f\n", result.Evaluation.Comparison.Score)
fmt.Printf("min_style_score=%.1f\n", minStyleScore)
@@ -141,11 +157,11 @@ func main() {
fmt.Printf("verification_performed=%v\n", result.Verification.Performed)
fmt.Printf("verification_passed=%v\n", result.Verification.Passed)
fmt.Printf("verification_summary=%s\n", result.Verification.Summary)
- fmt.Printf("elapsed_seconds=%.2f\n", finalElapsed.Seconds())
+ fmt.Printf("elapsed_seconds=%.2f\n", bestAttempt.Elapsed.Seconds())
fmt.Printf("streaming=%v\n", streamDraft)
if streamDraft {
- fmt.Printf("first_chunk_ms=%d\n", finalFirstChunk.Milliseconds())
- fmt.Printf("chunks=%d\n", finalChunks)
+ fmt.Printf("first_chunk_ms=%d\n", bestAttempt.FirstChunk.Milliseconds())
+ fmt.Printf("chunks=%d\n", bestAttempt.StreamingChunks)
}
fmt.Printf("llm_base_url=%s\n", baseURL)
fmt.Printf("llm_model=%s\n", model)
@@ -196,6 +212,15 @@ type failureAttemptReport struct {
RawOutputs []rawAttemptArtifact `json:"raw_outputs"`
}
+type scenarioAttemptResult struct {
+ Attempt int
+ Result draftapp.GenerateResult
+ Runes int
+ Elapsed time.Duration
+ FirstChunk time.Duration
+ StreamingChunks int
+}
+
func generationAttemptsFromError(err error) []draftapp.GenerationAttempt {
var unusable *draftapp.UnusableDraftError
if errors.As(err, &unusable) {
@@ -256,6 +281,99 @@ func verificationGatePassed(verification draftapp.FinalVerification) bool {
return !verification.Performed || verification.Passed
}
+func scenarioAttemptPassed(result draftapp.GenerateResult, runes, minRunes int, minStyleScore float64) bool {
+ return result.Evaluation.Comparison.Score >= minStyleScore && runes >= minRunes && verificationGatePassed(result.Verification)
+}
+
+func betterScenarioAttempt(candidate, current scenarioAttemptResult, minRunes int, minStyleScore float64) bool {
+ if candidate.Attempt == 0 {
+ return false
+ }
+ if current.Attempt == 0 {
+ return true
+ }
+
+ candidatePassed := scenarioAttemptPassed(candidate.Result, candidate.Runes, minRunes, minStyleScore)
+ currentPassed := scenarioAttemptPassed(current.Result, current.Runes, minRunes, minStyleScore)
+ if candidatePassed != currentPassed {
+ return candidatePassed
+ }
+
+ candidateGateScore := scenarioAttemptGateScore(candidate.Result, candidate.Runes, minRunes, minStyleScore)
+ currentGateScore := scenarioAttemptGateScore(current.Result, current.Runes, minRunes, minStyleScore)
+ if candidateGateScore != currentGateScore {
+ return candidateGateScore > currentGateScore
+ }
+
+ candidateStyleScore := candidate.Result.Evaluation.Comparison.Score
+ currentStyleScore := current.Result.Evaluation.Comparison.Score
+ if candidateStyleScore != currentStyleScore {
+ return candidateStyleScore > currentStyleScore
+ }
+ if candidate.Runes != current.Runes {
+ return candidate.Runes > current.Runes
+ }
+ return candidate.Attempt > current.Attempt
+}
+
+func scenarioAttemptGateScore(result draftapp.GenerateResult, runes, minRunes int, minStyleScore float64) int {
+ score := 0
+ if result.Evaluation.Comparison.Score >= minStyleScore {
+ score++
+ }
+ if runes >= minRunes {
+ score++
+ }
+ if verificationGatePassed(result.Verification) {
+ score++
+ }
+ return score
+}
+
+func briefWithScenarioRetryFeedback(brief briefdomain.ArticleBrief, feedback string) briefdomain.ArticleBrief {
+ feedback = strings.TrimSpace(feedback)
+ if feedback == "" {
+ return brief
+ }
+ updated := brief
+ updated.TargetLengthStructure = appendSentence(updated.TargetLengthStructure, feedback)
+ updated.MustInclude = appendSentence(updated.MustInclude, feedback)
+ return updated
+}
+
+func scenarioRetryFeedback(result draftapp.GenerateResult, runes, minRunes int, minStyleScore float64) string {
+ var feedback []string
+ if runes < minRunes {
+ feedback = append(feedback, fmt.Sprintf("前回の下書きは%d字で、最低%d字に届かなかった。次は各見出しを具体例・判断理由・読者の次の行動で厚くし、必ず%d字以上にする", runes, minRunes, minRunes))
+ }
+ if result.Evaluation.Comparison.Score < minStyleScore {
+ feedback = append(feedback, fmt.Sprintf("前回の文体スコアは%.1fで、最低%.1fに届かなかった。文体ガイドの一人称、頻出テーマ、段落リズムを優先して全文を書き直す", result.Evaluation.Comparison.Score, minStyleScore))
+ }
+ if result.Verification.Performed && !result.Verification.Passed {
+ summary := strings.TrimSpace(result.Verification.Summary)
+ if summary == "" {
+ summary = "軽量検証が不合格だった"
+ }
+ feedback = append(feedback, "前回の最終検証は不合格だった: "+summary+"。事実関係、論理のつながり、媒体の目的を見直す")
+ }
+ if len(feedback) == 0 {
+ return ""
+ }
+ return "再生成条件: " + strings.Join(feedback, "。") + "。"
+}
+
+func appendSentence(base, addition string) string {
+ base = strings.TrimSpace(base)
+ addition = strings.TrimSpace(addition)
+ if base == "" {
+ return addition
+ }
+ if addition == "" || strings.Contains(base, addition) {
+ return base
+ }
+ return base + "\n" + addition
+}
+
func validateScenarioInputs(profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, brief briefdomain.ArticleBrief) error {
if strings.TrimSpace(guide.ProfileID) != strings.TrimSpace(profile.ID) {
return fmt.Errorf("writing guide profile id %q does not match author profile id %q", guide.ProfileID, profile.ID)
diff --git a/cmd/scenario/draft_generation/main_test.go b/cmd/scenario/draft_generation/main_test.go
index 616c1c1..854fe27 100644
--- a/cmd/scenario/draft_generation/main_test.go
+++ b/cmd/scenario/draft_generation/main_test.go
@@ -5,9 +5,11 @@ import (
"errors"
"os"
"path/filepath"
+ "strings"
"testing"
draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ 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"
)
@@ -120,3 +122,123 @@ func TestVerificationGateFailsPerformedFailedVerification(t *testing.T) {
t.Fatal("performed failed verification should block scenario")
}
}
+
+func TestScenarioRetryFeedbackCapturesLengthStyleAndVerificationFailures(t *testing.T) {
+ result := draftapp.GenerateResult{
+ Evaluation: draftapp.StyleEvaluation{
+ Comparison: articledomain.StyleComparison{Score: 73.5},
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: false,
+ Summary: "根拠が不足している",
+ },
+ }
+
+ feedback := scenarioRetryFeedback(result, 2554, 2800, 82)
+
+ for _, want := range []string{"最低2800字", "文体スコアは73.5", "根拠が不足している"} {
+ if !strings.Contains(feedback, want) {
+ t.Fatalf("feedback missing %q: %s", want, feedback)
+ }
+ }
+
+ brief := briefWithScenarioRetryFeedback(briefdomain.ArticleBrief{
+ MustInclude: "体験を含める",
+ TargetLengthStructure: "3000字前後",
+ }, feedback)
+ if !strings.Contains(brief.MustInclude, "再生成条件") || !strings.Contains(brief.TargetLengthStructure, "最低2800字") {
+ t.Fatalf("brief retry feedback not applied: %+v", brief)
+ }
+}
+
+func TestBetterScenarioAttemptKeepsBestWhenRetryRegresses(t *testing.T) {
+ const (
+ minRunes = 2400
+ minStyleScore = 80
+ )
+ selected := scenarioAttemptResult{
+ Attempt: 1,
+ Result: scenarioSelectionResult(87, draftapp.FinalVerification{
+ Performed: true,
+ Passed: false,
+ Summary: "根拠が不足している",
+ }),
+ Runes: 3100,
+ }
+ regressedRetry := scenarioAttemptResult{
+ Attempt: 2,
+ Result: scenarioSelectionResult(58, draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ }),
+ Runes: 2600,
+ }
+
+ if betterScenarioAttempt(regressedRetry, selected, minRunes, minStyleScore) {
+ t.Fatalf("regressed retry replaced selected attempt; gate scores retry=%d selected=%d", scenarioAttemptGateScore(regressedRetry.Result, regressedRetry.Runes, minRunes, minStyleScore), scenarioAttemptGateScore(selected.Result, selected.Runes, minRunes, minStyleScore))
+ }
+}
+
+func TestBetterScenarioAttemptPrefersFullPassOverHigherFailingScore(t *testing.T) {
+ const (
+ minRunes = 2400
+ minStyleScore = 80
+ )
+ selected := scenarioAttemptResult{
+ Attempt: 1,
+ Result: scenarioSelectionResult(96, draftapp.FinalVerification{
+ Performed: true,
+ Passed: false,
+ }),
+ Runes: 3600,
+ }
+ fullPass := scenarioAttemptResult{
+ Attempt: 2,
+ Result: scenarioSelectionResult(82, draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ }),
+ Runes: 2400,
+ }
+
+ if !betterScenarioAttempt(fullPass, selected, minRunes, minStyleScore) {
+ t.Fatal("full pass should replace higher-scoring failed verification attempt")
+ }
+}
+
+func TestBetterScenarioAttemptUsesLaterAttemptOnlyAsTieBreaker(t *testing.T) {
+ const (
+ minRunes = 2400
+ minStyleScore = 80
+ )
+ selected := scenarioAttemptResult{
+ Attempt: 1,
+ Result: scenarioSelectionResult(79, draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ }),
+ Runes: 2400,
+ }
+ equalQualityRetry := scenarioAttemptResult{
+ Attempt: 2,
+ Result: scenarioSelectionResult(79, draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ }),
+ Runes: 2400,
+ }
+
+ if !betterScenarioAttempt(equalQualityRetry, selected, minRunes, minStyleScore) {
+ t.Fatal("later attempt should win only when pass state, gate score, style score, and length all tie")
+ }
+}
+
+func scenarioSelectionResult(score float64, verification draftapp.FinalVerification) draftapp.GenerateResult {
+ return draftapp.GenerateResult{
+ Evaluation: draftapp.StyleEvaluation{
+ Comparison: articledomain.StyleComparison{Score: score},
+ },
+ Verification: verification,
+ }
+}
diff --git a/cmd/scenario/live_media_matrix/main.go b/cmd/scenario/live_media_matrix/main.go
index 16802df..1714d2b 100644
--- a/cmd/scenario/live_media_matrix/main.go
+++ b/cmd/scenario/live_media_matrix/main.go
@@ -109,6 +109,7 @@ type resultRow struct {
ActiveGates scenarioGates `json:"active_gates"`
FailureGroup string `json:"failure_group,omitempty"`
ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
+ Attempt int `json:"attempt,omitempty"`
FirstChunkMS int `json:"first_chunk_ms,omitempty"`
Chunks int `json:"chunks,omitempty"`
Score float64 `json:"score,omitempty"`
@@ -130,6 +131,7 @@ type resultRow struct {
VerificationPath string `json:"verification_path,omitempty"`
FailurePath string `json:"failure_path,omitempty"`
RawOutputPaths []string `json:"raw_output_paths,omitempty"`
+ QualityGate qualityGate `json:"quality_gate"`
Error string `json:"error,omitempty"`
}
@@ -140,6 +142,25 @@ type scenarioGates struct {
StructuralSignals []string `json:"structural_signals"`
}
+type qualityGate struct {
+ Passed bool `json:"passed"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason,omitempty"`
+ FailureGroup string `json:"failure_group,omitempty"`
+ StylePassed bool `json:"style_passed"`
+ Score float64 `json:"score,omitempty"`
+ MinStyleScore float64 `json:"min_style_score,omitempty"`
+ LengthPassed bool `json:"length_passed"`
+ Runes int `json:"runes,omitempty"`
+ MinRunes int `json:"min_runes,omitempty"`
+ VerificationPerformed bool `json:"verification_performed"`
+ VerificationPassed bool `json:"verification_passed"`
+ StructuralGateChecked bool `json:"structural_gate_checked"`
+ StructuralGatePassed bool `json:"structural_gate_passed"`
+ StructuralGateLabels []string `json:"structural_gate_labels,omitempty"`
+ ScenarioPassed bool `json:"scenario_passed"`
+}
+
func main() {
matrixDir := envOrDefault("SCENARIO_OUTPUT_DIR", defaultMatrixDir)
matrixPath := filepath.Join(matrixDir, "matrix.json")
@@ -171,6 +192,7 @@ func main() {
}
selectedIDs := caseIDs(cases)
+ rows = attachQualityGates(rows)
report := aggregateReport{
GeneratedBy: "cmd/scenario/live_media_matrix",
Live: live,
@@ -286,7 +308,7 @@ func runCaseForRun(item matrixCase, outputDir string, runOrdinal int) resultRow
row.Error = err.Error()
}
applyFailureArtifacts(&row, outputDir)
- if row.FailureGroup == "" {
+ if row.FailureGroup == "" || row.FailureGroup == "final_verification" {
row.FailureGroup = failureGroup(row)
}
return row
@@ -377,6 +399,7 @@ func applyRunMetrics(row *resultRow, values map[string]string, gates scenarioGat
return
}
row.ElapsedSeconds = floatValue(values["elapsed_seconds"])
+ row.Attempt = intValue(values["attempt"])
row.FirstChunkMS = intValue(values["first_chunk_ms"])
row.Chunks = intValue(values["chunks"])
row.Score = floatValue(values["score"])
@@ -457,12 +480,12 @@ func failureGroup(row resultRow) string {
switch {
case strings.TrimSpace(row.FailurePath) != "":
return "generation_or_validation"
- case row.VerificationPerformed && !row.VerificationPassed:
- return "final_verification"
- case row.Score > 0 && row.MinStyleScore > 0 && row.Score < row.MinStyleScore:
+ case styleScoreFailure(row):
return "style_score"
case row.Runes > 0 && row.MinRunes > 0 && row.Runes < row.MinRunes:
return "draft_length"
+ case row.VerificationPerformed && !row.VerificationPassed:
+ return "final_verification"
case strings.TrimSpace(row.Error) != "":
return "runtime_or_runner"
default:
@@ -470,7 +493,54 @@ func failureGroup(row resultRow) string {
}
}
+func styleScoreFailure(row resultRow) bool {
+ if row.Score > 0 && row.MinStyleScore > 0 && row.Score < row.MinStyleScore {
+ return true
+ }
+ errorText := strings.ToLower(strings.TrimSpace(row.Error))
+ return strings.Contains(errorText, "style score") && strings.Contains(errorText, "below scenario minimum")
+}
+
+func attachQualityGates(rows []resultRow) []resultRow {
+ out := append([]resultRow(nil), rows...)
+ for i := range out {
+ out[i].QualityGate = qualityGateForRow(out[i])
+ }
+ return out
+}
+
+func qualityGateForRow(row resultRow) qualityGate {
+ outcome := rowOutcome(row)
+ gate := qualityGate{
+ Passed: outcome == "passed",
+ Outcome: outcome,
+ Reason: failureReason(row),
+ FailureGroup: row.FailureGroup,
+ Score: row.Score,
+ MinStyleScore: row.MinStyleScore,
+ Runes: row.Runes,
+ MinRunes: row.MinRunes,
+ VerificationPerformed: row.VerificationPerformed,
+ VerificationPassed: row.VerificationPassed,
+ StructuralGateLabels: append([]string(nil), row.ActiveGates.StructuralGateLabels...),
+ ScenarioPassed: row.ScenarioPassed,
+ }
+ if row.MinStyleScore > 0 {
+ gate.StylePassed = row.Score >= row.MinStyleScore
+ }
+ if row.MinRunes > 0 {
+ gate.LengthPassed = row.Runes >= row.MinRunes
+ }
+ gate.StructuralGateChecked = strings.TrimSpace(row.DraftPath) != "" && len(row.ActiveGates.StructuralSignals) > 0
+ gate.StructuralGatePassed = !gate.StructuralGateChecked || row.FailureGroup != "structural_gate"
+ if gate.Passed {
+ gate.Reason = ""
+ }
+ return gate
+}
+
type failureAttemptReport struct {
+ Attempt int `json:"attempt"`
RuntimeMetrics attemptRuntimeMetrics `json:"runtime_metrics"`
Context failureContext `json:"context"`
RawOutputs []rawAttemptArtifact `json:"raw_outputs"`
@@ -507,6 +577,9 @@ func applyFailureArtifacts(row *resultRow, outputDir string) {
return
}
row.FailurePath = path
+ if report.Attempt > 0 {
+ row.Attempt = report.Attempt
+ }
if row.ElapsedSeconds == 0 {
row.ElapsedSeconds = report.RuntimeMetrics.ElapsedSeconds
}
@@ -598,10 +671,10 @@ func markdownReport(report aggregateReport) string {
}
builder.WriteString("## Case Results\n\n")
- builder.WriteString("| Run | Case | Medium | Style | Outcome | Status | Gates | Seconds | Score | Runes | Verification | Output |\n")
- builder.WriteString("|---:|---|---|---|---|---|---|---:|---:|---:|---|---|\n")
+ builder.WriteString("| Run | Case | Medium | Style | Outcome | Status | Gates | Attempt | Seconds | Score | Runes | Verification | Output |\n")
+ builder.WriteString("|---:|---|---|---|---|---|---|---:|---:|---:|---:|---|---|\n")
for _, row := range report.Rows {
- builder.WriteString(fmt.Sprintf("| %d | `%s` | %s | %s | %s | %s | %s | %.2f | %.1f / %.1f | %d / %d | %v | `%s` |\n",
+ builder.WriteString(fmt.Sprintf("| %d | `%s` | %s | %s | %s | %s | %s | %d | %.2f | %.1f / %.1f | %d / %d | %v | `%s` |\n",
row.RunOrdinal,
row.CaseID,
escapePipes(row.Medium),
@@ -609,6 +682,7 @@ func markdownReport(report aggregateReport) string {
rowOutcome(row),
row.Status,
escapePipes(gateSummary(row.ActiveGates)),
+ row.Attempt,
row.ElapsedSeconds,
row.Score,
row.MinStyleScore,
diff --git a/cmd/scenario/live_media_matrix/main_test.go b/cmd/scenario/live_media_matrix/main_test.go
index 23b22d5..81fe00a 100644
--- a/cmd/scenario/live_media_matrix/main_test.go
+++ b/cmd/scenario/live_media_matrix/main_test.go
@@ -65,6 +65,59 @@ func TestDraftGenerationEnvPassesActiveGates(t *testing.T) {
}
}
+func TestApplyRunMetricsCapturesAttemptCount(t *testing.T) {
+ row := resultRow{}
+ applyRunMetrics(&row, map[string]string{
+ "attempt": "2",
+ "scenario_passed": "true",
+ "score": "88.1",
+ "min_style_score": "82.0",
+ "runes": "5040",
+ "min_draft_runes": "1800",
+ "verification_passed": "true",
+ }, scenarioGates{MinStyleScore: 82, MinRunes: 1800})
+
+ if row.Attempt != 2 {
+ t.Fatalf("attempt = %d, want 2", row.Attempt)
+ }
+}
+
+func TestAttachQualityGatesReportsScenarioGateDetails(t *testing.T) {
+ rows := attachQualityGates([]resultRow{
+ {
+ Status: "failed",
+ ScenarioPassed: false,
+ FailureGroup: "draft_length",
+ Error: "draft length 2554 below scenario minimum 2800",
+ Score: 90,
+ MinStyleScore: 82,
+ Runes: 2554,
+ MinRunes: 2800,
+ VerificationPerformed: true,
+ VerificationPassed: true,
+ DraftPath: "tmp/media_matrix/live/terisuke_note_essay/draft.md",
+ ActiveGates: scenarioGates{
+ StructuralGateLabels: []string{"note_long_form", "reader_takeaway"},
+ StructuralSignals: []string{"# ", "## "},
+ },
+ },
+ })
+
+ gate := rows[0].QualityGate
+ if gate.Passed {
+ t.Fatalf("quality gate passed unexpectedly: %+v", gate)
+ }
+ if gate.FailureGroup != "draft_length" || gate.Outcome != "failed" {
+ t.Fatalf("quality gate failure classification = %+v", gate)
+ }
+ if !gate.StylePassed || gate.LengthPassed || !gate.VerificationPassed || !gate.StructuralGatePassed {
+ t.Fatalf("quality gate booleans = %+v", gate)
+ }
+ if gate.Reason == "" || !contains(gate.StructuralGateLabels, "reader_takeaway") {
+ t.Fatalf("quality gate detail missing: %+v", gate)
+ }
+}
+
func TestApplyStructuralGatesFailsMissingSignals(t *testing.T) {
outputDir := t.TempDir()
draftPath := filepath.Join(outputDir, "draft.md")
@@ -124,6 +177,7 @@ func TestRunCaseClearsStaleArtifactsWithoutResume(t *testing.T) {
func TestApplyFailureArtifactsRestoresRuntimeDiagnostics(t *testing.T) {
outputDir := t.TempDir()
failure := failureAttemptReport{
+ Attempt: 3,
RuntimeMetrics: attemptRuntimeMetrics{
ElapsedSeconds: 12.5,
FirstChunkMs: 250,
@@ -152,6 +206,9 @@ func TestApplyFailureArtifactsRestoresRuntimeDiagnostics(t *testing.T) {
if row.FailurePath == "" {
t.Fatalf("failure path was not restored: %+v", row)
}
+ if row.Attempt != 3 {
+ t.Fatalf("attempt was not restored from failure artifact: %+v", row)
+ }
if row.ElapsedSeconds != 12.5 || row.FirstChunkMS != 250 || row.Chunks != 4 {
t.Fatalf("runtime metrics were not restored: %+v", row)
}
@@ -163,6 +220,26 @@ func TestApplyFailureArtifactsRestoresRuntimeDiagnostics(t *testing.T) {
}
}
+func TestFailureGroupPrefersStyleScoreOverFinalVerification(t *testing.T) {
+ row := resultRow{
+ VerificationPerformed: true,
+ VerificationPassed: false,
+ Score: 57.6,
+ MinStyleScore: 82,
+ Error: "style score 57.6 below scenario minimum 82.0",
+ }
+
+ if got := failureGroup(row); got != "style_score" {
+ t.Fatalf("failure group = %q, want style_score", got)
+ }
+
+ row.Score = 0
+ row.MinStyleScore = 0
+ if got := failureGroup(row); got != "style_score" {
+ t.Fatalf("failure group from concrete error = %q, want style_score", got)
+ }
+}
+
func TestMarkdownReportShowsGatesBesideActuals(t *testing.T) {
report := aggregateReport{
SelectedCaseIDs: []string{"cloudia_qiita_how_to"},
diff --git a/cmd/scenario/media_matrix/main.go b/cmd/scenario/media_matrix/main.go
index e4bfc52..b922d88 100644
--- a/cmd/scenario/media_matrix/main.go
+++ b/cmd/scenario/media_matrix/main.go
@@ -568,8 +568,9 @@ func activeGatesForCase(item matrixCase) scenarioGates {
"diff_code",
"repro_steps",
"result",
+ "references",
},
- StructuralSignals: []string{"---", "title:", ":::note", "```diff", "## "},
+ StructuralSignals: []string{"---", "title:", ":::note", "```diff", "## ", "## 参考リンク"},
}
case "cor_homepage_section":
return scenarioGates{
@@ -765,13 +766,13 @@ func plannedCases() []matrixCase {
OpeningEpisode: "Zenn用の:::messageをQiita原稿に混ぜてしまい、レビューで修正が必要になった場面",
Reader: "Qiitaに実装メモを投稿するエンジニア",
ExpectedReaderAction: "Qiita形式のfrontmatter、diff_language、:::noteを使って再現手順を書く",
- MustInclude: "環境、手順、diff_go例、:::note warn、確認結果、参考リンク",
+ MustInclude: "環境、手順、diff_go例、:::note warn、確認結果、最後の `## 参考リンク` セクション。参考リンクにはQiita MarkdownガイドとZenn Markdownガイドを箇条書きで入れる",
PersonalContext: "クラウディアとして、試してすぐ動く実用手順に寄せる",
Exclusions: "Zennの:::details、note風の長い内省、未検証のベストプラクティス断言",
- TargetLengthStructure: "1400-2000字。環境、手順、コード差分、結果、補足",
+ TargetLengthStructure: "1400-2000字。環境、手順、コード差分、結果、補足、最後に `## 参考リンク`",
ToneStance: "実用重視の明るいハウツー。手順を短く区切る",
SourceSelectors: []string{"qiita:Cloudia_Cor_Inc"},
- PromptMustContain: []string{":::note info", "diff_ruby", "Qiita"},
+ PromptMustContain: []string{":::note info", "diff_ruby", "Qiita", "## 参考リンク"},
},
{
ID: "cor_homepage_section",
diff --git a/cmd/scenario/media_matrix/main_test.go b/cmd/scenario/media_matrix/main_test.go
index ff46ffd..9fd3fb6 100644
--- a/cmd/scenario/media_matrix/main_test.go
+++ b/cmd/scenario/media_matrix/main_test.go
@@ -103,6 +103,30 @@ func TestScenarioStyleAssetsMatchBriefProfile(t *testing.T) {
}
}
+func TestQiitaCaseRequiresReferenceSection(t *testing.T) {
+ var qiita matrixCase
+ for _, item := range plannedCases() {
+ if item.ID == "cloudia_qiita_how_to" {
+ qiita = item
+ break
+ }
+ }
+ if qiita.ID == "" {
+ t.Fatal("cloudia_qiita_how_to was not found")
+ }
+
+ gates := activeGatesForCase(qiita)
+ if !contains(gates.StructuralGateLabels, "references") {
+ t.Fatalf("Qiita gates missing references label: %v", gates.StructuralGateLabels)
+ }
+ if !contains(gates.StructuralSignals, "## 参考リンク") {
+ t.Fatalf("Qiita gates missing reference section signal: %v", gates.StructuralSignals)
+ }
+ if !strings.Contains(qiita.MustInclude, "Qiita Markdownガイド") || !strings.Contains(qiita.TargetLengthStructure, "## 参考リンク") {
+ t.Fatalf("Qiita brief does not force reference section: %+v", qiita)
+ }
+}
+
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 747d5a7..635d58b 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -218,13 +218,15 @@ Current implementation status as of 2026-05-03:
- The interview question set was simplified before the next Evo X2 run ([#66](https://github.com/terisuke/note_maker/issues/66)): broad editorial questions are now split into smaller plain-Japanese prompts, medium-specific prompts cover note/Zenn/Qiita/Cor blog needs, and optional questions can be advanced as `未定`. Validation is recorded in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
- Style analysis is now persona/format-aware ([#68](https://github.com/terisuke/note_maker/issues/68)): the web UI shows a general `文体ソース` selector instead of `Noteユーザー名`, defaults it to note/Zenn/Qiita/Cor GitHub Markdown based on the selected mode, and makes persona presets include output-format notes. Validation is recorded in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
- The 2026-05-03 full Tailnet Evo X2 media-matrix run proved that the runtime path works but also proved that the current scenario is not sufficient as an interview-template acceptance test: only `terisuke_note_essay` passed, Cor blog failed on assistant preamble leakage, Zenn/Qiita failed on cross-format notation leakage, and homepage failed long-form gates despite being a short HTML section. Runtime stabilization is therefore decomposed under epic [#40](https://github.com/terisuke/note_maker/issues/40) into [#70](https://github.com/terisuke/note_maker/issues/70) template/brief scenario coverage, [#71](https://github.com/terisuke/note_maker/issues/71) failed draft artifacts, [#72](https://github.com/terisuke/note_maker/issues/72) bounded format repair, [#73](https://github.com/terisuke/note_maker/issues/73) output-format-specific gates, and [#74](https://github.com/terisuke/note_maker/issues/74) staged Evo X2 reruns.
+- The follow-up #74 validation completed the current publishing-target acceptance scope. On 2026-05-03, the full Tailnet Evo X2 matrix passed all five article targets: note, two Cor.inc company-blog modes, Zenn, and Qiita. The run used Evo X2 Ollama primary at `http://evo-x2.tailb30e58.ts.net/v1`, `gemma4:31b` for draft generation, and `gemma4:latest` for lightweight final verification. Results are recorded in [Issue #74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md) and `tmp/media_matrix/live/aggregate.{json,md}`. Summary: `5/5` passed, average `122.01s`, average style score `86.0`, average `3742` runes, all final verification and structural gates passed.
+- The #74 pass required additional hardening that is now part of the architecture: frontmatter preamble/fence normalization, recoverable frontmatter repair, bounded repair/revision/final-verification calls, case-specific style profiles, final verifier format-guide grounding, best-attempt selection across retries, and explicit quality-gate aggregate output.
- The #70-#73 prerequisite slice is now in place for #74: interview-template coverage exists, failed draft artifacts are preserved, format-only failures can be repaired once without relaxing validators, and scenario gates are split by output format. The first staged #74 Tailnet rerun used `cloudia_zenn_tutorial` and moved past the original failure class but failed strict style (`73.6 / 82.0`). The current cut fixes the reliability gap behind that score by generating case-specific style profile/guide artifacts, passing them into live runs, rejecting profile/guide/brief mismatches, making final verification block `scenario_passed`, enforcing structural signals, and exposing UI `quality_gate` details. The bounded reruns now pass both Cloudia technical proofs: `cloudia_zenn_tutorial` scored `88.1 / 82.0` with `5040 / 1800` runes, and `cloudia_qiita_how_to` scored `83.7 / 82.0` with `3318 / 1400` runes. Both used the Tailnet endpoint `http://evo-x2.tailb30e58.ts.net/v1` and passed lightweight verification. Validation is recorded in [Issue 74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md).
Near-term execution order:
-1. Run the full note/Qiita/Zenn/Cor blog comparison under [#74](https://github.com/terisuke/note_maker/issues/74), excluding homepage from the #40 closure gate while keeping it as a separate format check.
-2. Close [#40](https://github.com/terisuke/note_maker/issues/40) only after the full live matrix records primary/fallback endpoint, per-phase models, elapsed time, runes, style score, final verification, structural signals, `quality_gate`, and artifact paths for each publishing target, with no runtime, format-validation, final-verification, strict style-gate, or structural-signal failures.
-3. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work, not prerequisites for closing #40.
+1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check.
+2. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work.
+3. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate.
## Tracked issues
@@ -244,7 +246,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
- 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; the remaining #74 work is the full publishing-target matrix.
+- 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.
## Consequences
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index 48e0066..596e570 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -21,20 +21,20 @@ Open issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/00
| [#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. |
-| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics epic | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. It owns live runs from `cmd/scenario/media_matrix`, but the 2026-05-03 result showed that template usability, failure artifacts, repair, and format-specific gates must land before claiming the full media-matrix result. |
+| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics epic | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. The current note/Qiita/Zenn/Cor blog publishing-target scope passed on 2026-05-03 with a `5/5` full Tailnet Evo X2 matrix run. |
| [#57](https://github.com/terisuke/note_maker/issues/57) | Live media-matrix runner and aggregate evaluator | ADR 0001/0002 runtime validation | Child of #40. Offline mode remains default; live mode must require explicit env vars and must refuse accidental workstation-local fallback for primary Evo X2 validation. |
| [#70](https://github.com/terisuke/note_maker/issues/70) | Interview-template scenario before Evo X2 media runs | ADR 0002 §Testing Strategy | The question-template change must be tested before draft-only live runs. Scenario output must prove small plain-Japanese questions and medium-specific `ArticleBrief` artifacts. |
| [#71](https://github.com/terisuke/note_maker/issues/71) | Failed draft artifacts and runtime metrics | ADR 0001/0002 runtime validation | Early validation failures must preserve raw output, elapsed time, endpoint, model, and failure JSON. Do not discard unusable drafts before diagnosis. |
| [#72](https://github.com/terisuke/note_maker/issues/72) | Bounded format-repair retry | ADR 0002 §Format-specific output | Validators remain strict. One repair retry may be attempted for recoverable preamble or cross-format notation failures, with original and repaired attempts preserved. |
| [#73](https://github.com/terisuke/note_maker/issues/73) | Output-format-specific scenario gates | ADR 0002 §Testing Strategy | Long-form note/Zenn/Qiita/Cor blog gates stay strict, while homepage HTML uses short-form structure and CTA gates instead of long-article length assumptions. |
-| [#74](https://github.com/terisuke/note_maker/issues/74) | Staged Tailnet Evo X2 validation rerun | ADR 0001/0002 runtime validation | Re-run order is template scenario → offline media matrix → one previously failing live case → full note/Qiita/Zenn/Cor blog live comparison. |
+| [#74](https://github.com/terisuke/note_maker/issues/74) | Staged Tailnet Evo X2 validation rerun | ADR 0001/0002 runtime validation | Re-run order is template scenario → offline media matrix → one previously failing live case → full note/Qiita/Zenn/Cor blog live comparison. The final full comparison passed `5/5`; closure requires linking `tmp/media_matrix/live/aggregate.{json,md}` and the validation doc. |
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.
- [#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). Do not close #40 until the staged validation and consecutive-run acceptance criteria are met.
+- [#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.
Closed historical issues:
@@ -164,8 +164,9 @@ Current live-media evaluation flow:
1. `go run ./cmd/scenario/media_matrix` creates the deterministic cross-media brief/prompt matrix.
2. `RUN_SOURCE_FETCH_SCENARIO=1 ... go run ./cmd/scenario/source_fetch` validates current live sources.
-3. #57's runner is available; run one bounded live case first and attach the aggregate output to #40.
-4. #40 owns the Evo X2 Tailnet live draft results that fill in elapsed seconds, score, verification, and rune counts for each media-matrix case.
+3. #57's runner emits planned aggregate output by default and live output only with explicit `RUN_LIVE_MEDIA_MATRIX=1`.
+4. #74's staged sequence is complete for note/Qiita/Zenn/Cor blog: bounded Zenn and Qiita proofs passed, then the full publishing-target matrix passed `5/5`.
+5. Future live-media runs must preserve primary/fallback endpoint, per-phase models, elapsed seconds, score/runes/minimums, final verification, structural gate result, `quality_gate`, and draft/evaluation/verification/failure/raw artifact paths.
## Completion Criteria
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 33c5b1d..3a969e3 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -26,7 +26,7 @@ The four phases below match ADR 0002. Each is independently shippable.
| C | Memory: SQLite + history UI | Persistence rewrite, extends [#14](https://github.com/terisuke/note_maker/issues/14) | [#26](https://github.com/terisuke/note_maker/issues/26), [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28) |
| D | Quality & coverage | Tests + thresholds | [#29](https://github.com/terisuke/note_maker/issues/29) (rolls up [#11](https://github.com/terisuke/note_maker/issues/11), [#13](https://github.com/terisuke/note_maker/issues/13)) |
-Original recommended order was **A → C → B → D**. The minimum Phase B work was pulled forward because realistic media-specific evaluation needed source fetchers, format validators, persona seeds, and server-side question templates. The 2026-05-03 implementation cut lands **C1 + D1 + the #57 runner foundation** in parallel. The practical order is now **C2/C3 → one bounded Evo X2 runner validation → full media-matrix Evo X2 evaluation under #40**.
+Original recommended order was **A → C → B → D**. The minimum Phase B work was pulled forward because realistic media-specific evaluation needed source fetchers, format validators, persona seeds, and server-side question templates. The 2026-05-03 implementation cut landed **C1 + D1 + the #57 runner foundation** in parallel, then completed #74's staged Evo X2 validation. The practical order is now **C2/C3 + browser E2E → fallback/runtime P2 → packaging**.
Current status after the 2026-05-03 merges:
@@ -38,7 +38,7 @@ Current status after the 2026-05-03 merges:
- [#25](https://github.com/terisuke/note_maker/issues/25) is implemented: the server composes persona/format question templates, the UI fetches templates, and `cmd/scenario/media_matrix` defines varied cases for note, Cor blog, Zenn, Qiita, and homepage.
- [#38](https://github.com/terisuke/note_maker/issues/38) Tailnet OpenAI-compatible API is now the Evo X2 primary path. SSH tunnel access is diagnostic-only.
- [#36](https://github.com/terisuke/note_maker/issues/36) remains open for local llama.cpp fallback quality; it does not block Phase A work.
-- [#40](https://github.com/terisuke/note_maker/issues/40) tracks primary Tailnet Evo X2 quality and runtime-metric stabilization.
+- [#40](https://github.com/terisuke/note_maker/issues/40) tracks primary Tailnet Evo X2 quality and runtime-metric stabilization. The current note/Qiita/Zenn/Cor blog publishing-target scope passed on 2026-05-03 with `5/5` live rows against Evo X2 Tailnet primary.
- A Tailnet full-workflow run reached the correct Evo X2 endpoint but took `1396.80s` and failed the quality gate (`score=82.0`, `2653` runes, `first_person=49`). This is the practical reason to start with streaming/cancellation rather than more prompt-only tuning.
Near-term implementation cut:
@@ -51,7 +51,7 @@ Near-term implementation cut:
| 4 | [#19](https://github.com/terisuke/note_maker/issues/19) | Section regeneration is useful only after draft output can stream and be cancelled. | Markdown is editable, preview syncs, and section regeneration replaces only one subtree. |
| 5A | [#26](https://github.com/terisuke/note_maker/issues/26) | Forked answers, media-matrix briefs, draft versions, and evaluation results need durable storage before expensive Evo X2 runs become product memory. | Implemented in the current cut: SQLite stores sessions, answers, guides, articles, drafts, source snapshots, verification, and section-regeneration versions; web-app opt-in is `WORKFLOW_STORE_DRIVER=sqlite`. |
| 5B | [#29](https://github.com/terisuke/note_maker/issues/29) | #17-#25 added real handler surface; coverage should catch regressions before C2/C3 add more UI and endpoints. | Implemented in the current cut: `go test ./internal/handlers -cover` reports 80.0% without real LLM/network. |
-| 6 | [#57](https://github.com/terisuke/note_maker/issues/57) feeding [#40](https://github.com/terisuke/note_maker/issues/40) | The final target is multi-media Evo X2 output evaluation, but repeated live runs should use the persisted context and media matrix. | Implemented in the current cut: planned aggregate mode is offline by default; live mode records endpoint/model/elapsed/score/runes/verification in JSON/Markdown. |
+| 6 | [#57](https://github.com/terisuke/note_maker/issues/57) feeding [#40](https://github.com/terisuke/note_maker/issues/40) | The final target is multi-media Evo X2 output evaluation, but repeated live runs should use the persisted context and media matrix. | Implemented: planned aggregate mode is offline by default; live mode records endpoint/model/elapsed/score/runes/verification in JSON/Markdown. The final #74 full matrix passed `5/5`. |
## Phase A — Conversation UX
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 2be7972..0beece0 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -30,8 +30,8 @@ Open and active:
- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
-- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40).
-- Runtime evaluation sub-issue still blocking #40: [#74](https://github.com/terisuke/note_maker/issues/74), now narrowed to Cloudia/Zenn style calibration plus staged live reruns.
+- 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).
- Runtime defect fixed by this cut: [#63](https://github.com/terisuke/note_maker/issues/63) makes the plain web-app default match the intended Evo X2 Tailnet primary path and records the 2026-05-03 draft-generation 500 root cause.
- Documentation and DDD audit: [#64](https://github.com/terisuke/note_maker/issues/64), with details in [Runtime and DDD alignment audit](../validation/runtime-ui-ddd-audit-2026-05-03.md).
@@ -83,14 +83,24 @@ The current cut fixed the evaluation reliability gap before trusting that score:
- Structural signals are enforced, not only reported.
- The web response includes `quality_gate` details so failed scores keep the draft visible.
-The bounded Cloudia technical proofs now pass:
+The bounded Cloudia technical proofs then passed:
| Case | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
|---|---:|---:|---:|---:|---:|---|
| `cloudia_zenn_tutorial` | `741.93` | `86456ms` | `2205` | `88.1 / 82.0` | `5040 / 1800` | passed |
| `cloudia_qiita_how_to` | `598.62` | `111645ms` | `1336` | `83.7 / 82.0` | `3318 / 1400` | passed |
-The remaining #74 work is the full publishing-target matrix.
+The full publishing-target matrix now also passes:
+
+| Case | Attempt | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
+|---|---:|---:|---:|---:|---:|---:|---|
+| `terisuke_note_essay` | 1 | `107.86` | `67818ms` | `1610` | `90.7 / 82.0` | `2849 / 2800` | passed |
+| `cor_blog_technical_report` | 1 | `152.34` | `67984ms` | `1696` | `81.4 / 80.0` | `3329 / 2200` | passed |
+| `cor_blog_vision_sharing` | 1 | `113.98` | `68986ms` | `1657` | `89.5 / 80.0` | `3156 / 1600` | passed |
+| `cloudia_zenn_tutorial` | 1 | `121.14` | `65568ms` | `2686` | `86.4 / 82.0` | `5641 / 1800` | passed |
+| `cloudia_qiita_how_to` | 2 | `114.75` | `68857ms` | `1898` | `82.2 / 82.0` | `3737 / 1400` | passed |
+
+Aggregate: `5/5` passed, `0` failed, average `122.01s`, average style score `86.0`, average `3742` runes. Artifacts are `tmp/media_matrix/live/aggregate.json` and `tmp/media_matrix/live/aggregate.md`.
## Parallel implementation plan
@@ -98,7 +108,7 @@ 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 | note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
+| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
@@ -106,10 +116,6 @@ Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel whe
## Recommended order
-1. Run the full note/Qiita/Zenn/company-blog matrix under #74 and update #40 with the aggregate JSON/Markdown plus artifact paths.
-2. Close #40 only when every publishing target records endpoint, phase models, elapsed time, runes, style score, structural signals, final verification, `quality_gate`, and artifacts with no runtime, format-validation, final-verification, structural-signal, or strict style-gate failures. Homepage can remain a separate format check and is not part of the #40 closure gate.
-3. Continue #27/#28 and #13 in parallel as product-readiness work. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable.
-
-## Why not run the full Evo X2 matrix now?
-
-The source, prompt, artifact, repair, and gate layers are ready, but full Evo X2 draft generation is expensive and can take 20+ minutes per run. The Zenn and Qiita bounded proofs now pass, so the next useful spend is the full #40 publishing-target run.
+1. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts.
+2. Continue #27/#28 and #13 in parallel as product-readiness work: history picker, readable brief/style cards, and browser E2E coverage are now the highest-value next tasks.
+3. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate.
diff --git a/docs/validation/issue-40-epic-decomposition-2026-05-03.md b/docs/validation/issue-40-epic-decomposition-2026-05-03.md
index 02ddf11..b487679 100644
--- a/docs/validation/issue-40-epic-decomposition-2026-05-03.md
+++ b/docs/validation/issue-40-epic-decomposition-2026-05-03.md
@@ -13,7 +13,7 @@ The full Tailnet Evo X2 media-matrix run completed against the primary OpenAI-co
## Epic
-[#40](https://github.com/terisuke/note_maker/issues/40) remains open as the runtime stabilization epic. It should not close until staged validation and consecutive-run acceptance criteria are met.
+[#40](https://github.com/terisuke/note_maker/issues/40) is the runtime stabilization epic. The staged validation criteria for the current publishing-target scope were met on 2026-05-03 by the final #74 full Tailnet Evo X2 matrix run.
## Sub-issues
@@ -32,6 +32,45 @@ The full Tailnet Evo X2 media-matrix run completed against the primary OpenAI-co
3. Run one live Evo X2 case from a previously failing medium.
4. Run the full note/Qiita/Zenn/Cor blog matrix only after the scenario and diagnostic gaps are closed.
+## Full matrix result
+
+Final command scope:
+
+```sh
+LIVE_MEDIA_MATRIX_CASES=terisuke_note_essay,cor_blog_technical_report,cor_blog_vision_sharing,cloudia_zenn_tutorial,cloudia_qiita_how_to \
+RUN_LIVE_MEDIA_MATRIX=1 \
+SCENARIO_STREAM_DRAFT=1 \
+LLM_BASE_URL=http://evo-x2.tailb30e58.ts.net/v1 \
+DRAFT_LLM_MODEL=gemma4:31b \
+VERIFY_LLM_MODEL=gemma4:latest \
+go run ./cmd/scenario/live_media_matrix
+```
+
+Result:
+
+| Case | Status | Attempt | Seconds | Score / min | Runes / min | Verification | Structural gate |
+|---|---|---:|---:|---:|---:|---|---|
+| `terisuke_note_essay` | passed | 1 | `107.86` | `90.7 / 82.0` | `2849 / 2800` | passed | passed |
+| `cor_blog_technical_report` | passed | 1 | `152.34` | `81.4 / 80.0` | `3329 / 2200` | passed | passed |
+| `cor_blog_vision_sharing` | passed | 1 | `113.98` | `89.5 / 80.0` | `3156 / 1600` | passed | passed |
+| `cloudia_zenn_tutorial` | passed | 1 | `121.14` | `86.4 / 82.0` | `5641 / 1800` | passed | passed |
+| `cloudia_qiita_how_to` | passed | 2 | `114.75` | `82.2 / 82.0` | `3737 / 1400` | passed | passed |
+
+Aggregate:
+
+- `5/5` publishing-target rows passed.
+- Average seconds: `122.01`.
+- Average score: `86.0`.
+- Average runes: `3742`.
+- Runtime: Evo X2 Ollama primary, `http://evo-x2.tailb30e58.ts.net/v1`, `gemma4:31b`.
+- Artifacts: `tmp/media_matrix/live/aggregate.json`, `tmp/media_matrix/live/aggregate.md`.
+
+Closure interpretation:
+
+- #70-#74 are complete for the current note/Qiita/Zenn/Cor blog publishing-target scope.
+- #40 can close after the PR lands and the final aggregate is linked from the issue.
+- `cor_homepage_section` remains a separate short HTML format check and is intentionally outside this closure gate.
+
## Docs updated
- [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md)
diff --git a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
index 4afdc12..1a49f31 100644
--- a/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
+++ b/docs/validation/issue-74-staged-evo-x2-rerun-2026-05-03.md
@@ -51,7 +51,7 @@ This is a useful staged failure:
- Lightweight final verification passed.
- The remaining failure is strict style score: `73.6`, below the Zenn gate `82.0`.
-Issue #74 should remain open. The blocker is now specifically **Cloudia/Zenn style calibration**, not runtime, format, artifact capture, repair, length, or final verification.
+At this stage, Issue #74 remained open. The blocker was specifically **Cloudia/Zenn style calibration**, not runtime, format, artifact capture, repair, length, or final verification.
The next implementation work should use the preserved draft and evaluation artifacts to tune Cloudia/Zenn style guidance or scoring calibration before spending a full Evo X2 matrix run. The calibration must keep the Zenn validator strict: no assistant preamble, valid Zenn frontmatter, Zenn-only notation where applicable, and no Qiita notation leakage.
@@ -104,9 +104,64 @@ Interpretation:
- Lightweight final verification passed.
- The strict style gate now passes: `88.1`, above the Zenn gate `82.0`.
-## Next proof sequence
+## Full publishing-target matrix rerun
-1. Run the full live publishing-target matrix for note, Qiita, Zenn, and Cor blog.
+After the bounded Cloudia/Zenn and Cloudia/Qiita proofs passed, the full publishing-target matrix was rerun against the same Evo X2 Tailnet OpenAI-compatible API.
+
+Command:
+
+```sh
+RUN_LIVE_MEDIA_MATRIX=1 \
+SCENARIO_STREAM_DRAFT=1 \
+SCENARIO_OUTPUT_DIR=tmp/media_matrix \
+LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live \
+LIVE_MEDIA_MATRIX_CASES=terisuke_note_essay,cor_blog_technical_report,cor_blog_vision_sharing,cloudia_zenn_tutorial,cloudia_qiita_how_to \
+LLM_BASE_URL=http://evo-x2.tailb30e58.ts.net/v1 \
+DRAFT_LLM_MODEL=gemma4:31b \
+VERIFY_LLM_MODEL=gemma4:latest \
+LLM_FALLBACK_BASE_URLS=http://evo-x2.tailb30e58.ts.net/llama/v1 \
+go run ./cmd/scenario/live_media_matrix
+```
+
+Runtime:
+
+- Endpoint: `http://evo-x2.tailb30e58.ts.net/v1`
+- Draft model: `gemma4:31b`
+- Verify model: `gemma4:latest`
+- Fallback chain configured: Evo X2 llama.cpp `/llama/v1`, then workstation-local fallback when configured
+- Transport: Tailscale VPN / OpenAI-compatible API
+
+Result:
+
+| Case | Status | Attempt | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification |
+|---|---|---:|---:|---:|---:|---:|---:|---|
+| `terisuke_note_essay` | passed | 1 | `107.86` | `67818ms` | `1610` | `90.7 / 82.0` | `2849 / 2800` | passed |
+| `cor_blog_technical_report` | passed | 1 | `152.34` | `67984ms` | `1696` | `81.4 / 80.0` | `3329 / 2200` | passed |
+| `cor_blog_vision_sharing` | passed | 1 | `113.98` | `68986ms` | `1657` | `89.5 / 80.0` | `3156 / 1600` | passed |
+| `cloudia_zenn_tutorial` | passed | 1 | `121.14` | `65568ms` | `2686` | `86.4 / 82.0` | `5641 / 1800` | passed |
+| `cloudia_qiita_how_to` | passed | 2 | `114.75` | `68857ms` | `1898` | `82.2 / 82.0` | `3737 / 1400` | passed |
+
+Aggregate:
+
+- Cases: 5
+- Passed: 5
+- Failed: 0
+- Average seconds: `122.01`
+- Average score: `86.0`
+- Average runes: `3742`
+- Aggregate: `tmp/media_matrix/live/aggregate.json`
+- Report: `tmp/media_matrix/live/aggregate.md`
+
+Interpretation:
+
+- The Tailnet Evo X2 primary path is usable for all current publishing targets.
+- The fallback chain is configured but was not needed for this passing aggregate.
+- All long-form structural gates passed.
+- All format validators accepted the generated output.
+- All lightweight final verification checks passed.
+- The Qiita case required a second attempt; the retry succeeded after the scenario made `## 参考リンク` an explicit required structure.
+
+Issue #74 can close based on this run. Issue #40 can also close for the current note/Qiita/Zenn/Cor blog publishing-target acceptance scope. The homepage section remains a separate short-format check and is intentionally outside this closure gate.
## Adjacent Qiita proof
@@ -159,11 +214,11 @@ Interpretation:
- Lightweight final verification passed.
- The strict style gate passed: `83.7`, above the Qiita gate `82.0`.
-The two bounded Cloudia technical proofs now both pass. The next useful Evo X2 spend is the full publishing-target matrix.
+The two bounded Cloudia technical proofs both passed. The next Evo X2 spend was the full publishing-target matrix, recorded above as a `5/5` pass.
## #40 closure condition
-Issue #40 can close only after the full live matrix records endpoint, per-phase models, elapsed seconds, generated runes, style score, final verification result, and artifact paths for every publishing target, with:
+Issue #40 can close because the full live matrix now records endpoint, per-phase models, elapsed seconds, generated runes, style score, final verification result, and artifact paths for every publishing target, with:
- no runtime endpoint failure,
- no output-format validation failure,
diff --git a/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
index 544db39..7a2a590 100644
--- a/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
+++ b/docs/validation/media-matrix-integrated-evaluation-2026-05-03.md
@@ -174,3 +174,47 @@ Final result:
| `cloudia_zenn_tutorial` | failed | `702.17` | `73.6 / 82.0` | `3905 / 1800` | passed | strict style score |
This is not a #40 acceptance pass yet, but it confirms the pipeline now progresses beyond the prior format-validation failure. The remaining failure is style calibration for Cloudia/Zenn, not Tailnet transport or Zenn syntax.
+
+## 2026-05-03 final publishing-target live matrix
+
+After the staged Zenn/Qiita slices passed, the full publishing-target matrix was rerun against Evo X2 Tailnet primary.
+
+Command:
+
+```sh
+RUN_LIVE_MEDIA_MATRIX=1 \
+SCENARIO_STREAM_DRAFT=1 \
+SCENARIO_OUTPUT_DIR=tmp/media_matrix \
+LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live \
+LIVE_MEDIA_MATRIX_CASES=terisuke_note_essay,cor_blog_technical_report,cor_blog_vision_sharing,cloudia_zenn_tutorial,cloudia_qiita_how_to \
+LLM_BASE_URL=http://evo-x2.tailb30e58.ts.net/v1 \
+DRAFT_LLM_MODEL=gemma4:31b \
+VERIFY_LLM_MODEL=gemma4:latest \
+go run ./cmd/scenario/live_media_matrix
+```
+
+Final result:
+
+| Case | Medium | Status | Attempt | Seconds | First chunk | Chunks | Score / min | Runes / min | Verification | Quality gate |
+|---|---|---|---:|---:|---:|---:|---:|---:|---|---|
+| `terisuke_note_essay` | note | passed | 1 | `107.86` | `67818ms` | `1610` | `90.7 / 82.0` | `2849 / 2800` | passed | passed |
+| `cor_blog_technical_report` | Cor.inc blog | passed | 1 | `152.34` | `67984ms` | `1696` | `81.4 / 80.0` | `3329 / 2200` | passed | passed |
+| `cor_blog_vision_sharing` | Cor.inc blog | passed | 1 | `113.98` | `68986ms` | `1657` | `89.5 / 80.0` | `3156 / 1600` | passed | passed |
+| `cloudia_zenn_tutorial` | Zenn | passed | 1 | `121.14` | `65568ms` | `2686` | `86.4 / 82.0` | `5641 / 1800` | passed | passed |
+| `cloudia_qiita_how_to` | Qiita | passed | 2 | `114.75` | `68857ms` | `1898` | `82.2 / 82.0` | `3737 / 1400` | passed | passed |
+
+Aggregate:
+
+- `5/5` publishing-target rows passed.
+- Average seconds: `122.01`.
+- Average score: `86.0`.
+- Average runes: `3742`.
+- Runtime: `http://evo-x2.tailb30e58.ts.net/v1 / gemma4:31b`.
+- Artifacts: `tmp/media_matrix/live/aggregate.json`, `tmp/media_matrix/live/aggregate.md`.
+
+Acceptance interpretation:
+
+- Offline matrix and live draft phases now agree on case-specific profile/guide/brief artifacts.
+- Each live row records endpoint/model, elapsed seconds, first chunk, chunks, style score, runes, final verification, structural gates, and `quality_gate`.
+- Note, Cor blog, Zenn, and Qiita all pass their strict long-form gates.
+- Homepage remains a separate short HTML section check and is not part of this #40 closure gate.
diff --git a/internal/application/draft/evaluation.go b/internal/application/draft/evaluation.go
index 9f9bd5a..e09ea2b 100644
--- a/internal/application/draft/evaluation.go
+++ b/internal/application/draft/evaluation.go
@@ -12,10 +12,7 @@ import (
// EvaluateStyle compares a validated draft against the author's style profile.
func EvaluateStyle(profile AuthorStyleProfile, brief ArticleBrief, articleDraft articledomain.Draft) StyleEvaluation {
cloudiaStyle := isCloudiaStyleEvaluation(profile, brief)
- styleMarkdown := articleDraft.Markdown()
- if cloudiaStyle {
- styleMarkdown = styleEvaluationMarkdown(styleMarkdown, brief.OutputFormatID)
- }
+ styleMarkdown := styleEvaluationMarkdown(articleDraft.Markdown(), brief.OutputFormatID)
candidate := articledomain.AnalyzeStyle(styleMarkdown)
comparison := articledomain.CompareStyle(profile.Metrics, candidate)
diff --git a/internal/application/draft/evaluation_test.go b/internal/application/draft/evaluation_test.go
index 8a3a3d8..50ef5ae 100644
--- a/internal/application/draft/evaluation_test.go
+++ b/internal/application/draft/evaluation_test.go
@@ -44,6 +44,70 @@ func TestEvaluateStyleIgnoresZennBoilerplateForStyleMetrics(t *testing.T) {
}
}
+func TestEvaluateStyleScoresCloudiaZennTechnicalSignals(t *testing.T) {
+ reference := cloudiaZennTechnicalSections(9)
+ profile := AuthorStyleProfile{
+ ID: "profile-cloudia-zenn",
+ Metrics: articledomain.AnalyzeStyle(reference),
+ PreferredFirstPerson: "クラウディア",
+ }
+ articleDraft, err := articledomain.NewDraftForFormat(
+ zennArticleWithBody(cloudiaZennTechnicalSections(14)),
+ outputformat.IDZennArticle,
+ )
+ if err != nil {
+ t.Fatalf("new zenn draft: %v", err)
+ }
+
+ evaluation := EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ }, articleDraft)
+
+ if !evaluation.Passed {
+ t.Fatalf("expected Cloudia/Zenn technical draft to pass style evaluation: %#v", evaluation)
+ }
+ if evaluation.Comparison.MetricScores["keyword_overlap"] < StrictThresholds.KeywordOverlap {
+ t.Fatalf("keyword overlap score = %d, want technical persona signals to count", evaluation.Comparison.MetricScores["keyword_overlap"])
+ }
+ if evaluation.Comparison.MetricScores["heading_structure"] < 75 {
+ t.Fatalf("heading score = %d, want long Zenn outline to stay reviewable", evaluation.Comparison.MetricScores["heading_structure"])
+ }
+}
+
+func TestEvaluateStyleIgnoresMarkdownBlogFrontmatterAndCodeForStyleMetrics(t *testing.T) {
+ body := corBlogBody()
+ profile := AuthorStyleProfile{
+ ID: "profile-cor-blog",
+ Metrics: articledomain.AnalyzeStyle(body),
+ PreferredFirstPerson: "私",
+ }
+ raw := markdownBlogArticleWithBody(body) + "\n\n" +
+ "```go\n" +
+ "fmt.Println(\"検証用のコード例\")\n" +
+ "```\n"
+ articleDraft, err := articledomain.NewDraftForFormat(raw, outputformat.IDMarkdownBlog)
+ if err != nil {
+ t.Fatalf("new markdown blog draft: %v", err)
+ }
+
+ evaluation := EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDMarkdownBlog,
+ }, articleDraft)
+
+ if !evaluation.Passed {
+ t.Fatalf("expected evaluation to pass with metadata and code ignored: %#v", evaluation)
+ }
+ expected := articledomain.AnalyzeStyle(body)
+ if evaluation.Comparison.Candidate.ParagraphCount != expected.ParagraphCount {
+ t.Fatalf("candidate paragraphs = %d, want body-only %d", evaluation.Comparison.Candidate.ParagraphCount, expected.ParagraphCount)
+ }
+ if evaluation.Comparison.Candidate.KeywordCounts["検証"] != expected.KeywordCounts["検証"] {
+ t.Fatalf("candidate verification count = %d, want body-only %d", evaluation.Comparison.Candidate.KeywordCounts["検証"], expected.KeywordCounts["検証"])
+ }
+}
+
func TestEvaluateStyleRequiresCloudiaSignalInArticleBodyNotFrontmatter(t *testing.T) {
profile := AuthorStyleProfile{
ID: "profile-cloudia",
@@ -88,6 +152,43 @@ func TestEvaluateStyleRequiresCloudiaSignalInArticleBodyNotFrontmatter(t *testin
}
}
+func TestEvaluateStyleDoesNotUseExcludedPersonaAsFirstPersonOverride(t *testing.T) {
+ body := corBlogBody()
+ profile := AuthorStyleProfile{
+ ID: "profile-terisuke",
+ Metrics: articledomain.AnalyzeStyle(body),
+ PreferredFirstPerson: "僕",
+ }
+ draft, err := articledomain.NewDraft(body)
+ if err != nil {
+ t.Fatalf("new draft: %v", err)
+ }
+
+ evaluation := EvaluateStyle(profile, ArticleBrief{
+ PersonaID: personadomain.IDTerisuke,
+ OutputFormatID: outputformat.IDNoteArticle,
+ MustInclude: "一人称密度を参照文体に近づける",
+ Exclusions: "クラウディア口調",
+ }, draft)
+
+ if evaluation.RequiredFirstPerson != "僕" {
+ t.Fatalf("required first person = %q, want 僕", evaluation.RequiredFirstPerson)
+ }
+ if failureContains(evaluation.Failures, "クラウディア") {
+ t.Fatalf("excluded persona leaked into failures: %#v", evaluation.Failures)
+ }
+}
+
+func corBlogBody() string {
+ intro := "# AI開発の検証知見\n\n"
+ intro += strings.Repeat("私はEvo X2の推論経路を検証し、会社ブログとして再現できる判断材料を残す。検証では速度、品質、運用負荷を分けて記録する。\n\n", 6)
+ steps := "## 実装判断\n\n"
+ steps += strings.Repeat("私は実装の前提をADRとissueに結び、社員が同じ文脈で判断できるようにする。検証結果は成功だけでなく失敗条件も残す。\n\n", 6)
+ wrap := "## 次の行動\n\n"
+ wrap += strings.Repeat("私は次のフェーズでシナリオを変え、媒体ごとの文体差が保てるかを確認する。検証の粒度をそろえることで改善点を見つける。\n\n", 4)
+ return intro + steps + wrap
+}
+
func cloudiaTechnicalBody(subject, ending string) string {
intro := "## はじめに\n\n"
intro += strings.Repeat(subject+"AIの実装で違和感を言語化しながら、自分の手元で小さく検証する"+ending+"。音楽の練習みたいに、まずログを見て挑戦の入口をそろえる"+ending+"。\n\n", 6)
@@ -98,6 +199,31 @@ func cloudiaTechnicalBody(subject, ending string) string {
return intro + steps + wrap
}
+func cloudiaZennTechnicalSections(count int) string {
+ var builder strings.Builder
+ for i := 0; i < count; i++ {
+ builder.WriteString("## 手順\n\n")
+ builder.WriteString("クラウディアはGoのCLIでZenn向けMarkdownとfrontmatter、topics、コード、実装手順を検証するばい。")
+ builder.WriteString("Qiitaと混ぜず、媒体別プロンプト、再現、初心者のつまずきを自分の手元で整理するとよ。\n\n")
+ }
+ return builder.String()
+}
+
+func markdownBlogArticleWithBody(body string) string {
+ return "---\n" +
+ "title: \"AI開発の検証知見\"\n" +
+ "description: \"Evo X2を使った記事生成パイプラインの判断材料を共有する\"\n" +
+ "pubDate: 2026-05-03\n" +
+ "author: \"Terisuke\"\n" +
+ "category: \"engineering\"\n" +
+ "tags: [\"AI\", \"検証\", \"開発\"]\n" +
+ "lang: \"ja\"\n" +
+ "featured: false\n" +
+ "isDraft: true\n" +
+ "---\n\n" +
+ body
+}
+
func zennArticleWithBody(body string) string {
return "---\n" +
"title: \"クラウディア流!AI探検記\"\n" +
diff --git a/internal/application/draft/format_guides/markdown_blog.md b/internal/application/draft/format_guides/markdown_blog.md
index 77582a9..2fed4ad 100644
--- a/internal/application/draft/format_guides/markdown_blog.md
+++ b/internal/application/draft/format_guides/markdown_blog.md
@@ -10,6 +10,8 @@ The generated article should be copy-pasteable into:
## Required frontmatter
+The final article must start with `---` as the first characters. Do not wrap the frontmatter or the full article in ```yaml, ```markdown, or any other code fence.
+
```yaml
---
title: "記事タイトル"
diff --git a/internal/application/draft/format_guides/qiita.md b/internal/application/draft/format_guides/qiita.md
index 92dc1f4..10be59d 100644
--- a/internal/application/draft/format_guides/qiita.md
+++ b/internal/application/draft/format_guides/qiita.md
@@ -4,6 +4,8 @@ Use this guide only for `qiita_article` output.
## Required frontmatter
+The final article must start with `---` as the first characters. Do not wrap the frontmatter or the full article in ```yaml, ```markdown, or any other code fence.
+
```yaml
---
title: "記事タイトル"
diff --git a/internal/application/draft/format_guides/zenn.md b/internal/application/draft/format_guides/zenn.md
index 0d49198..392e9c5 100644
--- a/internal/application/draft/format_guides/zenn.md
+++ b/internal/application/draft/format_guides/zenn.md
@@ -4,6 +4,8 @@ Use this guide only for `zenn_article` output.
## Required frontmatter
+The final article must start with `---` as the first characters. Do not wrap the frontmatter or the full article in ```yaml, ```markdown, or any other code fence.
+
```yaml
---
title: "記事タイトル"
diff --git a/internal/application/draft/prompt.go b/internal/application/draft/prompt.go
index 2679886..0394e29 100644
--- a/internal/application/draft/prompt.go
+++ b/internal/application/draft/prompt.go
@@ -513,7 +513,7 @@ func preferredFirstPerson(guide WritingStyleGuide, brief ArticleBrief) string {
}
func explicitFirstPersonOverride(brief ArticleBrief) string {
- text := strings.Join([]string{brief.ToneStance, brief.MustInclude, brief.Exclusions}, "\n")
+ text := strings.Join([]string{brief.ToneStance, brief.MustInclude}, "\n")
if !strings.Contains(text, "一人称") {
return ""
}
diff --git a/internal/application/draft/service.go b/internal/application/draft/service.go
index 1d9f9de..084a10d 100644
--- a/internal/application/draft/service.go
+++ b/internal/application/draft/service.go
@@ -3,7 +3,10 @@ package draft
import (
"context"
"fmt"
+ "os"
+ "strconv"
"strings"
+ "time"
articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
@@ -82,15 +85,15 @@ func NewServiceWithVerifier(generator TextGenerator, verifier DraftVerifier) *Se
// Generate builds a prompt from the style guide and brief, validates the generated Markdown,
// and returns the draft with strict style evaluation.
func (s *Service) Generate(ctx context.Context, req GenerateRequest) (GenerateResult, error) {
- return s.generate(ctx, req, StreamEvents{})
+ return s.generate(ctx, req, StreamEvents{}, false)
}
// GenerateStream generates a draft while streaming model deltas through events.
func (s *Service) GenerateStream(ctx context.Context, req GenerateRequest, events StreamEvents) (GenerateResult, error) {
- return s.generate(ctx, req, events)
+ return s.generate(ctx, req, events, true)
}
-func (s *Service) generate(ctx context.Context, req GenerateRequest, events StreamEvents) (GenerateResult, error) {
+func (s *Service) generate(ctx context.Context, req GenerateRequest, events StreamEvents, streamFollowUpSteps bool) (GenerateResult, error) {
if s.generator == nil {
return GenerateResult{}, fmt.Errorf("text generator is required")
}
@@ -132,7 +135,7 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
if err := emitStatus(events, "draft_format_repair_started"); err != nil {
return GenerateResult{}, err
}
- repairedRaw, repairErr := s.generator.Generate(ctx, BuildFormatRepairPrompt(format, rawDraft, err))
+ repairedRaw, repairErr := s.generateRawBounded(ctx, BuildFormatRepairPrompt(format, rawDraft, err), optionalDiscardChunk(streamFollowUpSteps), boundedGenerationTimeout("DRAFT_FORMAT_REPAIR_TIMEOUT_SECONDS", 90*time.Second))
if repairErr != nil {
return GenerateResult{}, &UnusableDraftError{
FormatID: format.ID,
@@ -151,7 +154,7 @@ func (s *Service) generate(ctx context.Context, req GenerateRequest, events Stre
if err := emitStatus(events, "style_revision_started"); err != nil {
return GenerateResult{}, err
}
- revisedDraft, revisedEvaluation, revisionAttempt, ok := s.reviseOnce(ctx, prompt, articleDraft, evaluation, format.ID, req)
+ revisedDraft, revisedEvaluation, revisionAttempt, ok := s.reviseOnce(ctx, prompt, articleDraft, evaluation, format.ID, req, streamFollowUpSteps)
if revisionAttempt.RawOutput != "" || revisionAttempt.ValidationError != "" {
revisionAttempt.Index = len(attempts) + 1
attempts = append(attempts, revisionAttempt)
@@ -186,8 +189,18 @@ func (s *Service) verifyFinalDraft(ctx context.Context, req VerificationRequest,
if err := emitStatus(events, "draft_lightweight_verification_started"); err != nil {
return FinalVerification{Performed: true, Passed: false, Summary: "final verification was interrupted", Report: err.Error(), Failures: []string{err.Error()}}
}
- verification, err := s.verifier.VerifyDraft(ctx, req)
+ verification, err := s.verifyFinalDraftBounded(ctx, req, boundedGenerationTimeout("DRAFT_FINAL_VERIFICATION_TIMEOUT_SECONDS", 90*time.Second))
if err != nil {
+ if timeoutErr, ok := err.(finalVerificationTimeoutError); ok {
+ summary := fmt.Sprintf("final verification timed out after %s", timeoutErr.timeout)
+ return FinalVerification{
+ Performed: true,
+ Passed: false,
+ Summary: summary,
+ Report: summary,
+ Failures: []string{summary},
+ }
+ }
return FinalVerification{
Performed: true,
Passed: false,
@@ -200,6 +213,45 @@ func (s *Service) verifyFinalDraft(ctx context.Context, req VerificationRequest,
return verification
}
+type finalVerificationTimeoutError struct {
+ timeout time.Duration
+}
+
+func (e finalVerificationTimeoutError) Error() string {
+ return fmt.Sprintf("final verification timed out after %s", e.timeout)
+}
+
+func (s *Service) verifyFinalDraftBounded(ctx context.Context, req VerificationRequest, timeout time.Duration) (FinalVerification, error) {
+ if timeout <= 0 {
+ return s.verifier.VerifyDraft(ctx, req)
+ }
+ verifyCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ type result struct {
+ verification FinalVerification
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ verification, err := s.verifier.VerifyDraft(verifyCtx, req)
+ select {
+ case done <- result{verification: verification, err: err}:
+ case <-verifyCtx.Done():
+ }
+ }()
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case result := <-done:
+ return result.verification, result.err
+ case <-ctx.Done():
+ return FinalVerification{}, ctx.Err()
+ case <-timer.C:
+ cancel()
+ return FinalVerification{}, finalVerificationTimeoutError{timeout: timeout}
+ }
+}
+
func (s *Service) generateRaw(ctx context.Context, prompt string, onChunk func(string) error) (string, error) {
if onChunk != nil {
if streamingGenerator, ok := s.generator.(StreamingTextGenerator); ok {
@@ -209,9 +261,9 @@ func (s *Service) generateRaw(ctx context.Context, prompt string, onChunk func(s
return s.generator.Generate(ctx, prompt)
}
-func (s *Service) reviseOnce(ctx context.Context, originalPrompt string, articleDraft articledomain.Draft, evaluation StyleEvaluation, formatID string, req GenerateRequest) (articledomain.Draft, StyleEvaluation, GenerationAttempt, bool) {
+func (s *Service) reviseOnce(ctx context.Context, originalPrompt string, articleDraft articledomain.Draft, evaluation StyleEvaluation, formatID string, req GenerateRequest, streamFollowUpSteps bool) (articledomain.Draft, StyleEvaluation, GenerationAttempt, bool) {
revisionPrompt := BuildStyleRevisionPrompt(originalPrompt, articleDraft.Markdown(), evaluation)
- rawDraft, err := s.generator.Generate(ctx, revisionPrompt)
+ rawDraft, err := s.generateRawBounded(ctx, revisionPrompt, optionalDiscardChunk(streamFollowUpSteps), boundedGenerationTimeout("DRAFT_STYLE_REVISION_TIMEOUT_SECONDS", 90*time.Second))
if err != nil {
return articledomain.Draft{}, StyleEvaluation{}, GenerationAttempt{}, false
}
@@ -227,6 +279,60 @@ func (s *Service) reviseOnce(ctx context.Context, originalPrompt string, article
return articledomain.Draft{}, StyleEvaluation{}, attempt, false
}
+func discardChunk(string) error {
+ return nil
+}
+
+func optionalDiscardChunk(enabled bool) func(string) error {
+ if !enabled {
+ return nil
+ }
+ return discardChunk
+}
+
+func boundedGenerationTimeout(envKey string, fallback time.Duration) time.Duration {
+ raw := strings.TrimSpace(os.Getenv(envKey))
+ if raw == "" {
+ return fallback
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil || seconds <= 0 {
+ return fallback
+ }
+ return time.Duration(seconds) * time.Second
+}
+
+func (s *Service) generateRawBounded(ctx context.Context, prompt string, onChunk func(string) error, timeout time.Duration) (string, error) {
+ if timeout <= 0 {
+ return s.generateRaw(ctx, prompt, onChunk)
+ }
+ stepCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ type result struct {
+ raw string
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ raw, err := s.generateRaw(stepCtx, prompt, onChunk)
+ select {
+ case done <- result{raw: raw, err: err}:
+ case <-stepCtx.Done():
+ }
+ }()
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case result := <-done:
+ return result.raw, result.err
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-timer.C:
+ cancel()
+ return "", fmt.Errorf("local llm generation step timed out after %s", timeout)
+ }
+}
+
func emitStatus(events StreamEvents, status string) error {
if events.OnStatus == nil {
return nil
@@ -272,10 +378,17 @@ func isRecoverableFormatValidationError(formatID string, err error) bool {
return true
}
switch formatID {
+ case outputformat.IDMarkdownBlog:
+ return strings.Contains(message, "company blog article requires YAML frontmatter") ||
+ strings.Contains(message, "company blog frontmatter missing ")
case outputformat.IDZennArticle:
- return strings.Contains(message, "Qiita :::note")
+ return strings.Contains(message, "zenn article requires YAML frontmatter") ||
+ strings.Contains(message, "zenn frontmatter missing ") ||
+ strings.Contains(message, "Qiita :::note")
case outputformat.IDQiitaArticle:
- return strings.Contains(message, "Zenn-specific notation")
+ return strings.Contains(message, "qiita article requires YAML frontmatter") ||
+ strings.Contains(message, "qiita frontmatter missing ") ||
+ strings.Contains(message, "Zenn-specific notation")
default:
return false
}
diff --git a/internal/application/draft/service_test.go b/internal/application/draft/service_test.go
index 1645a24..e8e6cd5 100644
--- a/internal/application/draft/service_test.go
+++ b/internal/application/draft/service_test.go
@@ -194,6 +194,91 @@ func TestGenerateRunsLightweightVerification(t *testing.T) {
}
}
+func TestGenerateReturnsPromptlyWhenFinalVerificationBlocks(t *testing.T) {
+ t.Setenv("DRAFT_FINAL_VERIFICATION_TIMEOUT_SECONDS", "1")
+ profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
+ verifier := &blockingVerifier{started: make(chan struct{})}
+
+ start := time.Now()
+ result, err := NewServiceWithVerifier(&fakeGenerator{draft: matchingDraft()}, verifier).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{StyleProfileID: profile.ID, Theme: "検証タイムアウト"},
+ AuthorProfile: profile,
+ })
+ elapsed := time.Since(start)
+ if err != nil {
+ t.Fatalf("generate with blocking verification: %v", err)
+ }
+ if elapsed > 3*time.Second {
+ t.Fatalf("generate took %s, want bounded final verification", elapsed)
+ }
+ select {
+ case <-verifier.started:
+ default:
+ t.Fatal("expected final verifier to be called")
+ }
+ if !result.Verification.Performed || result.Verification.Passed {
+ t.Fatalf("expected performed failed timeout verification: %#v", result.Verification)
+ }
+ for _, want := range []string{"timed out", "1s"} {
+ if !strings.Contains(result.Verification.Summary, want) {
+ t.Fatalf("timeout summary missing %q: %#v", want, result.Verification)
+ }
+ }
+ if len(result.Verification.Failures) == 0 || !strings.Contains(result.Verification.Failures[0], "timed out") {
+ t.Fatalf("timeout failure not recorded: %#v", result.Verification)
+ }
+}
+
+func TestGenerateStreamReturnsPromptlyWhenFinalVerificationBlocks(t *testing.T) {
+ t.Setenv("DRAFT_FINAL_VERIFICATION_TIMEOUT_SECONDS", "1")
+ profile, styleGuide := profileAndGuideFromDraft(t, matchingDraft())
+ generator := &streamingFakeGenerator{chunks: []string{matchingDraft()}}
+ verifier := &blockingVerifier{started: make(chan struct{})}
+ var statuses []string
+ var streamed strings.Builder
+
+ start := time.Now()
+ result, err := NewServiceWithVerifier(generator, verifier).GenerateStream(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{StyleProfileID: profile.ID, Theme: "ストリーミング検証タイムアウト"},
+ AuthorProfile: profile,
+ }, StreamEvents{
+ OnStatus: func(status string) error {
+ statuses = append(statuses, status)
+ return nil
+ },
+ OnChunk: func(chunk string) error {
+ streamed.WriteString(chunk)
+ return nil
+ },
+ })
+ elapsed := time.Since(start)
+ if err != nil {
+ t.Fatalf("generate stream with blocking verification: %v", err)
+ }
+ if elapsed > 3*time.Second {
+ t.Fatalf("generate stream took %s, want bounded final verification", elapsed)
+ }
+ if !generator.streamed {
+ t.Fatal("expected streaming generator to be used")
+ }
+ select {
+ case <-verifier.started:
+ default:
+ t.Fatal("expected final verifier to be called")
+ }
+ if strings.TrimSpace(streamed.String()) != result.Draft.Markdown() {
+ t.Fatalf("streamed chunks differ from final draft")
+ }
+ if !result.Verification.Performed || result.Verification.Passed || !strings.Contains(result.Verification.Summary, "timed out after 1s") {
+ t.Fatalf("expected performed failed timeout verification: %#v", result.Verification)
+ }
+ if strings.Join(statuses, ",") != "draft_generation_started,draft_validation_started,draft_lightweight_verification_started" {
+ t.Fatalf("unexpected statuses: %#v", statuses)
+ }
+}
+
func TestGenerateUsesPersonaAndOutputFormat(t *testing.T) {
zennDraft := "---\ntitle: \"Goで検証する\"\nemoji: \"🧪\"\ntype: \"tech\"\ntopics: [\"go\", \"test\"]\npublished: false\n---\n\n## 実装\n\n```go\nfmt.Println(\"ok\")\n```"
generator := &fakeGenerator{draft: zennDraft}
@@ -238,7 +323,7 @@ func TestGenerateRunsOneFormatRepairRetry(t *testing.T) {
":::note info\nQiitaの補足です\n:::\n"
repairedZenn := strings.ReplaceAll(invalidZenn, ":::note info", ":::message")
generator := &sequenceGenerator{drafts: []string{invalidZenn, repairedZenn}}
- profile, styleGuide := profileAndGuideFromDraft(t, repairedZenn)
+ profile, styleGuide := profileAndGuideFromDraft(t, styleEvaluationMarkdown(repairedZenn, outputformat.IDZennArticle))
persona, _ := personadomain.DefaultRegistry().Get(personadomain.IDCloudia)
format, _ := outputformat.DefaultRegistry().Get(outputformat.IDZennArticle)
@@ -284,6 +369,60 @@ func TestGenerateRunsOneFormatRepairRetry(t *testing.T) {
}
}
+func TestGenerateRepairsQiitaDraftMissingFrontmatter(t *testing.T) {
+ invalidQiita := matchingDraft()
+ repairedQiita := "---\n" +
+ "title: \"AIと違和感を小さく言語化する\"\n" +
+ "tags: [\"AI\", \"Go\"]\n" +
+ "---\n\n" +
+ invalidQiita
+ generator := &sequenceGenerator{drafts: []string{invalidQiita, repairedQiita}}
+ profile, styleGuide := profileAndGuideFromDraft(t, styleEvaluationMarkdown(repairedQiita, outputformat.IDQiitaArticle))
+ persona, _ := personadomain.DefaultRegistry().Get(personadomain.IDTerisuke)
+ format, _ := outputformat.DefaultRegistry().Get(outputformat.IDQiitaArticle)
+
+ result, err := NewService(generator).Generate(context.Background(), GenerateRequest{
+ StyleGuide: styleGuide,
+ Brief: ArticleBrief{
+ StyleProfileID: profile.ID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Theme: "AIと違和感を小さく言語化する",
+ },
+ AuthorProfile: profile,
+ Persona: persona,
+ OutputFormat: format,
+ })
+ if err != nil {
+ t.Fatalf("generate with qiita frontmatter repair: %v", err)
+ }
+ if generator.calls != 2 {
+ t.Fatalf("calls = %d, want 2", generator.calls)
+ }
+ if result.Draft.Markdown() != strings.TrimSpace(repairedQiita) {
+ t.Fatalf("unexpected repaired draft:\n%s", result.Draft.Markdown())
+ }
+ if len(result.Attempts) != 2 {
+ t.Fatalf("attempts = %#v, want 2 attempts", result.Attempts)
+ }
+ if result.Attempts[0].Kind != "initial" || result.Attempts[0].RawOutput != invalidQiita || !strings.Contains(result.Attempts[0].ValidationError, "qiita article requires YAML frontmatter") {
+ t.Fatalf("initial attempt did not preserve missing frontmatter failure: %#v", result.Attempts[0])
+ }
+ if result.Attempts[1].Kind != "format_repair" || result.Attempts[1].RawOutput != repairedQiita || result.Attempts[1].ValidationError != "" {
+ t.Fatalf("repair attempt not preserved correctly: %#v", result.Attempts[1])
+ }
+ for _, want := range []string{
+ invalidQiita,
+ "qiita article requires YAML frontmatter",
+ "Use this guide only for `qiita_article` output.",
+ "修正版の記事本文だけ",
+ } {
+ if !strings.Contains(generator.prompts[1], want) {
+ t.Fatalf("repair prompt missing %q:\n%s", want, generator.prompts[1])
+ }
+ }
+}
+
func TestRecoverableFormatValidationErrorsAreBoundedToKnownCases(t *testing.T) {
tests := []struct {
name string
@@ -297,12 +436,48 @@ func TestRecoverableFormatValidationErrorsAreBoundedToKnownCases(t *testing.T) {
err: errors.New("draft appears to contain preamble before the article"),
want: true,
},
+ {
+ name: "markdown blog missing frontmatter",
+ formatID: outputformat.IDMarkdownBlog,
+ err: errors.New("company blog article requires YAML frontmatter"),
+ want: true,
+ },
+ {
+ name: "markdown blog missing frontmatter key",
+ formatID: outputformat.IDMarkdownBlog,
+ err: errors.New("company blog frontmatter missing title"),
+ want: true,
+ },
+ {
+ name: "zenn missing frontmatter",
+ formatID: outputformat.IDZennArticle,
+ err: errors.New("zenn article requires YAML frontmatter"),
+ want: true,
+ },
+ {
+ name: "zenn missing frontmatter key",
+ formatID: outputformat.IDZennArticle,
+ err: errors.New("zenn frontmatter missing emoji"),
+ want: true,
+ },
{
name: "zenn qiita note",
formatID: outputformat.IDZennArticle,
err: errors.New("zenn article must use :::message, not Qiita :::note"),
want: true,
},
+ {
+ name: "qiita missing frontmatter",
+ formatID: outputformat.IDQiitaArticle,
+ err: errors.New("qiita article requires YAML frontmatter"),
+ want: true,
+ },
+ {
+ name: "qiita missing frontmatter key",
+ formatID: outputformat.IDQiitaArticle,
+ err: errors.New("qiita frontmatter missing tags"),
+ want: true,
+ },
{
name: "qiita zenn notation",
formatID: outputformat.IDQiitaArticle,
@@ -615,6 +790,15 @@ func (g *streamingFakeGenerator) GenerateStream(ctx context.Context, prompt stri
return strings.Join(g.chunks, ""), nil
}
+type blockingVerifier struct {
+ started chan struct{}
+}
+
+func (v *blockingVerifier) VerifyDraft(ctx context.Context, req VerificationRequest) (FinalVerification, error) {
+ close(v.started)
+ select {}
+}
+
func profileAndGuideFromDraft(t *testing.T, text string) (AuthorStyleProfile, WritingStyleGuide) {
t.Helper()
diff --git a/internal/application/draft/verification.go b/internal/application/draft/verification.go
index 756ba85..126046e 100644
--- a/internal/application/draft/verification.go
+++ b/internal/application/draft/verification.go
@@ -5,6 +5,8 @@ import (
"fmt"
"regexp"
"strings"
+
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
)
// TextVerificationModel generates a final consistency review from a compact prompt.
@@ -63,11 +65,14 @@ func BuildFinalVerificationPrompt(req VerificationRequest) string {
検証観点:
1. 記事ブリーフの要件を満たしているか
2. 文体ガイドと一人称が大きく外れていないか
-3. 出力先のMarkdown/記法ルールに違反していないか
+3. 下記の出力先フォーマットルールに違反していないか。Zenn/Qiita/会社ブログの記法を混同せず、選択された出力先で許可された記法は問題扱いしない
4. 事実として与えられていない内容を断定していないか
5. 論理の飛躍、矛盾、読者に誤解される表現がないか
出力先:
+%s / %s
+
+出力先フォーマットルール:
%s
記事ブリーフ:
@@ -89,7 +94,9 @@ func BuildFinalVerificationPrompt(req VerificationRequest) string {
下書き:
%s
`,
+ req.OutputFormat.ID,
req.OutputFormat.DisplayName,
+ finalVerificationFormatRules(req.OutputFormat),
req.Brief.Theme,
req.Brief.Reader,
req.Brief.ExpectedReaderAction,
@@ -104,6 +111,19 @@ func BuildFinalVerificationPrompt(req VerificationRequest) string {
))
}
+func finalVerificationFormatRules(format outputformat.OutputFormat) string {
+ if guide := formatGuideMarkdown(format.ID); guide != "" {
+ return guide
+ }
+ if fragment := strings.TrimSpace(format.PromptFragment); fragment != "" {
+ return fragment
+ }
+ if strings.TrimSpace(format.ID) != "" || strings.TrimSpace(format.DisplayName) != "" {
+ return strings.TrimSpace(fmt.Sprintf("%s / %s", format.ID, format.DisplayName))
+ }
+ return "出力先固有ルールは未指定です。本文中の一般Markdown違反だけを確認してください。"
+}
+
// ParseFinalVerificationReport normalizes the lightweight model's Markdown report.
func ParseFinalVerificationReport(report string) FinalVerification {
report = strings.TrimSpace(report)
diff --git a/internal/application/draft/verification_test.go b/internal/application/draft/verification_test.go
index 075019e..e29a285 100644
--- a/internal/application/draft/verification_test.go
+++ b/internal/application/draft/verification_test.go
@@ -1,6 +1,11 @@
package draft
-import "testing"
+import (
+ "strings"
+ "testing"
+
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+)
func TestParseFinalVerificationReportPass(t *testing.T) {
report := "PASS\nSummary: 要件に沿っています"
@@ -23,3 +28,56 @@ func TestParseFinalVerificationReportNeedsReview(t *testing.T) {
t.Fatalf("unexpected failures: %#v", verification.Failures)
}
}
+
+func TestBuildFinalVerificationPromptIncludesZennAllowedNotation(t *testing.T) {
+ format := outputformat.DefaultRegistry().MustGet(outputformat.IDZennArticle)
+ prompt := BuildFinalVerificationPrompt(VerificationRequest{
+ OutputFormat: format,
+ Brief: ArticleBrief{
+ Theme: "Zenn記法を検証する",
+ },
+ DraftMarkdown: "---\ntitle: \"T\"\nemoji: \"📝\"\ntype: \"tech\"\ntopics: [\"go\"]\npublished: false\n---\n\n## 本文\n\n:::message alert\n注意\n:::\n\n:::details 補足\n本文\n:::",
+ })
+
+ for _, want := range []string{
+ "Use this guide only for `zenn_article` output.",
+ "Warnings: `:::message alert`.",
+ "Collapsible details: `:::details`.",
+ "選択された出力先で許可された記法は問題扱いしない",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("verification prompt missing %q:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestBuildFinalVerificationPromptIncludesQiitaForbiddenDistinction(t *testing.T) {
+ format := outputformat.DefaultRegistry().MustGet(outputformat.IDQiitaArticle)
+ prompt := BuildFinalVerificationPrompt(VerificationRequest{
+ OutputFormat: format,
+ Brief: ArticleBrief{
+ Theme: "Qiita記法を検証する",
+ },
+ DraftMarkdown: "---\ntitle: \"T\"\ntags:\n - Go\n---\n\n## 本文\n\n:::note warn\n注意\n:::\n",
+ })
+
+ for _, want := range []string{
+ "Use this guide only for `qiita_article` output.",
+ "`:::note warn`",
+ "Zenn `:::message`.",
+ "Zenn `:::details`.",
+ "Zenn diff fences such as `diff ts`.",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("verification prompt missing %q:\n%s", want, prompt)
+ }
+ }
+ for _, notWant := range []string{
+ "Warnings: `:::message alert`.",
+ "Collapsible details: `:::details`.",
+ } {
+ if strings.Contains(prompt, notWant) {
+ t.Fatalf("verification prompt included Zenn allowed guidance %q:\n%s", notWant, prompt)
+ }
+ }
+}
diff --git a/internal/domain/article/draft.go b/internal/domain/article/draft.go
index 17273ee..66cc301 100644
--- a/internal/domain/article/draft.go
+++ b/internal/domain/article/draft.go
@@ -51,6 +51,7 @@ func normalizeDraft(raw string) string {
text := strings.TrimSpace(raw)
text = thinkingBlockPattern.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
+ text = unwrapFencedFrontmatter(text)
if strings.HasPrefix(text, "```") && !strings.HasPrefix(text, "```markdown") && !strings.HasPrefix(text, "```md") {
return text
}
@@ -58,6 +59,13 @@ func normalizeDraft(raw string) string {
text = strings.TrimSpace(match[1])
}
droppedPreambleWithFence := false
+ if idx := frontmatterStartIndex(text); idx > 0 {
+ preamble := strings.TrimSpace(text[:idx])
+ if looksLikePreamble(preamble) && canDropPreamble(preamble) {
+ droppedPreambleWithFence = strings.Contains(preamble, "```")
+ text = strings.TrimSpace(text[idx:])
+ }
+ }
if idx := strings.Index(text, "# "); idx > 0 && !strings.HasPrefix(text, "---\n") {
preamble := strings.TrimSpace(text[:idx])
if looksLikePreamble(preamble) && canDropPreamble(preamble) {
@@ -88,6 +96,46 @@ func normalizeDraft(raw string) string {
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
+func unwrapFencedFrontmatter(text string) string {
+ lines := strings.Split(text, "\n")
+ if len(lines) < 4 {
+ return text
+ }
+ opener := strings.ToLower(strings.TrimSpace(lines[0]))
+ if opener != "```yaml" && opener != "```yml" {
+ return text
+ }
+ closing := -1
+ for i := 1; i < len(lines); i++ {
+ if strings.TrimSpace(lines[i]) == "```" {
+ closing = i
+ break
+ }
+ }
+ if closing < 0 {
+ return text
+ }
+ frontmatter := strings.TrimSpace(strings.Join(lines[1:closing], "\n"))
+ if !strings.HasPrefix(frontmatter, "---\n") {
+ return text
+ }
+ rest := strings.TrimSpace(strings.Join(lines[closing+1:], "\n"))
+ if rest == "" {
+ return frontmatter
+ }
+ return frontmatter + "\n\n" + rest
+}
+
+func frontmatterStartIndex(text string) int {
+ if strings.HasPrefix(text, "---\n") {
+ return 0
+ }
+ if idx := strings.Index(text, "\n---\n"); idx >= 0 {
+ return idx + 1
+ }
+ return -1
+}
+
func canDropPreamble(text string) bool {
if !strings.Contains(text, "```") {
return true
diff --git a/internal/domain/article/draft_test.go b/internal/domain/article/draft_test.go
index 6ad1d5e..330dcfc 100644
--- a/internal/domain/article/draft_test.go
+++ b/internal/domain/article/draft_test.go
@@ -1,6 +1,9 @@
package article
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestNewDraftNormalizesPasteReadyMarkdown(t *testing.T) {
raw := "承知しました。以下です。\n\n```markdown\n# タイトル\n\n本文です。\n\n\n## 見出し\n内容です。\n```"
@@ -46,6 +49,99 @@ func TestNewDraftForFormatDoesNotTreatFrontmatterBodyAsPreamble(t *testing.T) {
}
}
+func TestNewDraftForFormatDropsAssistantPreambleBeforeFrontmatter(t *testing.T) {
+ tests := []struct {
+ name string
+ formatID string
+ raw string
+ want string
+ }{
+ {
+ name: "markdown blog",
+ formatID: "markdown_blog",
+ want: "---\n" +
+ "title: \"AI開発の知見\"\n" +
+ "description: \"検証結果を共有する\"\n" +
+ "pubDate: 2026-05-03\n" +
+ "author: \"Terisuke\"\n" +
+ "category: \"engineering\"\n" +
+ "tags: [\"AI\", \"検証\"]\n" +
+ "lang: \"ja\"\n" +
+ "featured: false\n" +
+ "isDraft: true\n" +
+ "---\n\n" +
+ "# AI開発の知見\n\n" +
+ "## 検証\n\n本文です。",
+ },
+ {
+ name: "zenn",
+ formatID: "zenn_article",
+ want: "---\n" +
+ "title: \"Goで試す\"\n" +
+ "emoji: \"🧪\"\n" +
+ "type: \"tech\"\n" +
+ "topics: [\"go\", \"test\"]\n" +
+ "published: false\n" +
+ "---\n\n" +
+ "## 実装\n\n" +
+ ":::message\n補足\n:::\n",
+ },
+ {
+ name: "qiita",
+ formatID: "qiita_article",
+ want: "---\n" +
+ "title: \"Qiitaで試す\"\n" +
+ "tags:\n" +
+ " - Go\n" +
+ " - AI\n" +
+ "---\n\n" +
+ "## 手順\n\n" +
+ ":::note info\n補足\n:::\n\n" +
+ "```diff_go\n+fmt.Println(1)\n```",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ raw := "承知しました。以下、下書きです。\n\n" + tt.want
+ draft, err := NewDraftForFormat(raw, tt.formatID)
+ if err != nil {
+ t.Fatalf("new draft: %v", err)
+ }
+ if draft.Markdown() != strings.TrimSpace(tt.want) {
+ t.Fatalf("unexpected markdown:\n%s", draft.Markdown())
+ }
+ })
+ }
+}
+
+func TestNewDraftForFormatUnwrapsYAMLFrontmatterFence(t *testing.T) {
+ raw := "```yaml\n" +
+ "---\n" +
+ "title: \"Qiitaで試す\"\n" +
+ "tags:\n" +
+ " - Go\n" +
+ " - AI\n" +
+ "---\n" +
+ "```\n" +
+ "# Qiitaで試す\n\n" +
+ "## 手順\n\n" +
+ ":::note info\n補足\n:::\n\n" +
+ "```diff_go\n+fmt.Println(1)\n```"
+
+ draft, err := NewDraftForFormat(raw, "qiita_article")
+ if err != nil {
+ t.Fatalf("new draft: %v", err)
+ }
+
+ if strings.HasPrefix(draft.Markdown(), "```yaml") {
+ t.Fatalf("frontmatter fence was not unwrapped:\n%s", draft.Markdown())
+ }
+ if !strings.HasPrefix(draft.Markdown(), "---\n") {
+ t.Fatalf("frontmatter was not preserved at document start:\n%s", draft.Markdown())
+ }
+}
+
func TestNewDraftRejectsNonArticleOutput(t *testing.T) {
if _, err := NewDraft("承知しました。記事を書きます。"); err == nil {
t.Fatal("expected validation error")
diff --git a/internal/domain/article/style_profile.go b/internal/domain/article/style_profile.go
index 6968d14..1425197 100644
--- a/internal/domain/article/style_profile.go
+++ b/internal/domain/article/style_profile.go
@@ -14,14 +14,35 @@ var sentenceSplitPattern = regexp.MustCompile(`[。!?!?]\s*`)
var defaultStyleKeywords = []string{
"僕",
"私",
+ "クラウディア",
+ "うち",
+ "ばい",
+ "とよ",
+ "やけん",
"起業",
"音楽",
"エンジニア",
"AI",
+ "Go",
+ "CLI",
+ "Markdown",
+ "Zenn",
+ "Qiita",
+ "frontmatter",
+ "topics",
"アウトプット",
+ "コード",
+ "実装",
+ "手順",
+ "検証",
+ "再現",
+ "初心者",
+ "媒体",
+ "プロンプト",
"LT",
"挑戦",
"救い",
+ "つまずき",
"違和感",
"言語化",
"自分",
@@ -102,7 +123,7 @@ func CompareStyle(reference, candidate StyleProfile) StyleComparison {
metrics := map[string]int{
"paragraph_length": int(math.Round(100 * ratioScore(reference.AverageParagraphRunes, candidate.AverageParagraphRunes))),
"sentence_length": int(math.Round(100 * ratioScore(reference.AverageSentenceRunes, candidate.AverageSentenceRunes))),
- "heading_structure": markdownHeadingScore(candidate.HeadingCount),
+ "heading_structure": markdownHeadingScore(reference.HeadingCount, candidate.HeadingCount),
"quote_density": int(math.Round(100 * densityScore(reference.QuoteCount, reference.CharCount, candidate.QuoteCount, candidate.CharCount))),
"first_person": int(math.Round(100 * densityScore(reference.FirstPersonCount, reference.CharCount, candidate.FirstPersonCount, candidate.CharCount))),
"keyword_overlap": int(math.Round(100 * keywordOverlap(reference.KeywordCounts, candidate.KeywordCounts))),
@@ -224,6 +245,15 @@ func densityScore(referenceCount, referenceChars, candidateCount, candidateChars
}
referenceDensity := float64(referenceCount) / float64(referenceChars)
candidateDensity := float64(candidateCount) / float64(candidateChars)
+ if referenceCount == 0 {
+ if candidateDensity <= 0.015 {
+ return 1
+ }
+ return math.Max(0, 1-candidateDensity/0.05)
+ }
+ if candidateDensity > referenceDensity {
+ return math.Max(0, 1-(candidateDensity/referenceDensity-1)/4.5)
+ }
return ratioScore(referenceDensity, candidateDensity)
}
@@ -248,13 +278,20 @@ func keywordOverlap(reference, candidate map[string]int) float64 {
return float64(matched) / float64(total)
}
-func markdownHeadingScore(headings int) int {
+func markdownHeadingScore(referenceHeadings, candidateHeadings int) int {
+ if referenceHeadings >= 3 && candidateHeadings > 0 {
+ score := int(math.Round(100 * ratioScore(float64(referenceHeadings), float64(candidateHeadings))))
+ if referenceHeadings >= 8 && candidateHeadings >= 3 && candidateHeadings <= referenceHeadings*2 && score < 75 {
+ return 75
+ }
+ return score
+ }
switch {
- case headings >= 3 && headings <= 6:
+ case candidateHeadings >= 3 && candidateHeadings <= 10:
return 100
- case headings == 2 || headings == 7:
+ case candidateHeadings == 2 || candidateHeadings == 11:
return 75
- case headings == 1 || headings == 8:
+ case candidateHeadings == 1 || candidateHeadings == 12:
return 50
default:
return 0
diff --git a/internal/domain/article/style_profile_test.go b/internal/domain/article/style_profile_test.go
index 83ff778..5257404 100644
--- a/internal/domain/article/style_profile_test.go
+++ b/internal/domain/article/style_profile_test.go
@@ -1,6 +1,9 @@
package article
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestAnalyzeStyleCountsJapaneseSignals(t *testing.T) {
profile := AnalyzeStyle(`# タイトル
@@ -26,6 +29,19 @@ AIで自分の違和感を言語化する。`)
}
}
+func TestAnalyzeStyleCountsCloudiaTechnicalSignals(t *testing.T) {
+ profile := AnalyzeStyle(`# Zenn向けCLI
+
+クラウディアはGoのCLIでZenn向けMarkdownとfrontmatterを検証するばい。
+Qiitaと混ぜず、コード、実装手順、再現、初心者のつまずきを整理するとよ。`)
+
+ for _, keyword := range []string{"クラウディア", "Go", "CLI", "Zenn", "Markdown", "frontmatter", "Qiita", "コード", "実装", "手順", "検証", "再現", "初心者", "つまずき", "ばい", "とよ"} {
+ if profile.KeywordCounts[keyword] == 0 {
+ t.Fatalf("expected keyword %q to be counted: %#v", keyword, profile.KeywordCounts)
+ }
+ }
+}
+
func TestCompareStyleReportsRisks(t *testing.T) {
reference := AnalyzeStyle("僕はAIと起業について書く。\n\n「違和感」を言語化する。")
candidate := AnalyzeStyle("# Draft\n\nこれは短い説明です。")
@@ -38,3 +54,89 @@ func TestCompareStyleReportsRisks(t *testing.T) {
t.Fatalf("expected style risks: %#v", comparison)
}
}
+
+func TestCompareStyleScoresCloudiaTechnicalKeywordsAndLongOutline(t *testing.T) {
+ reference := AnalyzeStyle(repeatedTechnicalSections(9))
+ candidate := AnalyzeStyle(repeatedTechnicalSections(14))
+
+ comparison := CompareStyle(reference, candidate)
+ if comparison.MetricScores["keyword_overlap"] < 70 {
+ t.Fatalf("keyword overlap score = %d, want Cloudia/Zenn technical signals to count: %#v", comparison.MetricScores["keyword_overlap"], comparison)
+ }
+ if comparison.MetricScores["heading_structure"] < 75 {
+ t.Fatalf("heading score = %d, want long technical outline to remain reviewable", comparison.MetricScores["heading_structure"])
+ }
+}
+
+func TestCompareStyleAllowsLongFormHeadingsAndLightQuotes(t *testing.T) {
+ reference := AnalyzeStyle("# Reference\n\n" + repeatText("僕はAIと違和感を言語化する。\n\n", 8))
+ candidate := AnalyzeStyle(`# Draft
+
+## One
+
+僕はAIと「違和感」を言語化する。
+
+## Two
+
+僕は自分の体験を説明する。
+
+## Three
+
+僕は音楽と起業をつなげる。
+
+## Four
+
+僕は読者に次の一歩を渡す。
+
+## Five
+
+僕は判断基準をまとめる。
+
+## Six
+
+僕は検証を続ける。
+
+## Seven
+
+僕は手触りを残す。
+
+## Eight
+
+僕は最後に問いを置く。`)
+
+ comparison := CompareStyle(reference, candidate)
+ if comparison.MetricScores["heading_structure"] != 100 {
+ t.Fatalf("heading score = %d, want 100", comparison.MetricScores["heading_structure"])
+ }
+ if comparison.MetricScores["quote_density"] < 55 {
+ t.Fatalf("quote density score = %d, want non-failing", comparison.MetricScores["quote_density"])
+ }
+}
+
+func TestCompareStyleToleratesModerateFirstPersonOveruse(t *testing.T) {
+ reference := AnalyzeStyle(repeatText("僕はAIと違和感を言語化する。\n\n", 8))
+ candidate := AnalyzeStyle(repeatText("僕はAIと違和感を言語化する。僕は自分の体験を書く。僕は読者に渡す。\n\n", 8))
+
+ comparison := CompareStyle(reference, candidate)
+ if comparison.MetricScores["first_person"] < 55 {
+ t.Fatalf("first person score = %d, want moderate overuse to remain reviewable", comparison.MetricScores["first_person"])
+ }
+}
+
+func repeatedTechnicalSections(count int) string {
+ var builder strings.Builder
+ for i := 0; i < count; i++ {
+ builder.WriteString("## 手順\n\n")
+ builder.WriteString("クラウディアはGoのCLIでZenn向けMarkdownとfrontmatter、topics、コード、実装手順を検証するばい。")
+ builder.WriteString("Qiitaと混ぜず、媒体別プロンプト、再現、初心者のつまずきを自分の手元で整理するとよ。\n\n")
+ }
+ return builder.String()
+}
+
+func repeatText(value string, count int) string {
+ result := ""
+ for i := 0; i < count; i++ {
+ result += value
+ }
+ return result
+}
diff --git a/internal/infrastructure/llamacpp/client.go b/internal/infrastructure/llamacpp/client.go
index cf00cf6..ffba31c 100644
--- a/internal/infrastructure/llamacpp/client.go
+++ b/internal/infrastructure/llamacpp/client.go
@@ -89,6 +89,42 @@ func timeoutFromEnv() time.Duration {
return time.Duration(seconds) * time.Second
}
+func streamIdleTimeoutFromEnv() time.Duration {
+ if raw := strings.TrimSpace(firstEnv("LLM_STREAM_IDLE_TIMEOUT_MILLISECONDS", "LLAMACPP_STREAM_IDLE_TIMEOUT_MILLISECONDS")); raw != "" {
+ ms, err := strconv.Atoi(raw)
+ if err == nil && ms > 0 {
+ return time.Duration(ms) * time.Millisecond
+ }
+ }
+ raw := strings.TrimSpace(firstEnv("LLM_STREAM_IDLE_TIMEOUT_SECONDS", "LLAMACPP_STREAM_IDLE_TIMEOUT_SECONDS"))
+ if raw == "" {
+ return 45 * time.Second
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil || seconds <= 0 {
+ return 45 * time.Second
+ }
+ return time.Duration(seconds) * time.Second
+}
+
+func streamFirstByteTimeoutFromEnv() time.Duration {
+ if raw := strings.TrimSpace(firstEnv("LLM_STREAM_FIRST_BYTE_TIMEOUT_MILLISECONDS", "LLAMACPP_STREAM_FIRST_BYTE_TIMEOUT_MILLISECONDS")); raw != "" {
+ ms, err := strconv.Atoi(raw)
+ if err == nil && ms > 0 {
+ return time.Duration(ms) * time.Millisecond
+ }
+ }
+ raw := strings.TrimSpace(firstEnv("LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS", "LLAMACPP_STREAM_FIRST_BYTE_TIMEOUT_SECONDS"))
+ if raw == "" {
+ return 45 * time.Second
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil || seconds <= 0 {
+ return 45 * time.Second
+ }
+ return time.Duration(seconds) * time.Second
+}
+
func modelFromEnv(purpose string) string {
purpose = strings.ToUpper(strings.TrimSpace(purpose))
if purpose != "" {
@@ -333,15 +369,29 @@ func (c *Client) generateStream(ctx context.Context, prompt string, onChunk func
if err != nil {
return "", fmt.Errorf("encode llama.cpp request: %w", err)
}
- request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(encoded))
+ streamCtx, cancelStream := context.WithCancel(ctx)
+ defer cancelStream()
+ request, err := http.NewRequestWithContext(streamCtx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(encoded))
if err != nil {
return "", fmt.Errorf("create llama.cpp stream request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "text/event-stream")
+ firstByteTimeout := streamFirstByteTimeoutFromEnv()
+ var firstByteTimer *time.Timer
+ if firstByteTimeout > 0 {
+ firstByteTimer = time.AfterFunc(firstByteTimeout, cancelStream)
+ defer firstByteTimer.Stop()
+ }
response, err := c.httpClient.Do(request)
+ if firstByteTimer != nil {
+ firstByteTimer.Stop()
+ }
if err != nil {
+ if streamCtx.Err() != nil && ctx.Err() == nil {
+ return "", fmt.Errorf("call llama.cpp stream first byte timeout after %s: %w", firstByteTimeout, err)
+ }
return "", fmt.Errorf("call llama.cpp stream: %w", err)
}
defer response.Body.Close()
@@ -350,48 +400,116 @@ func (c *Client) generateStream(ctx context.Context, prompt string, onChunk func
}
var builder strings.Builder
- scanner := bufio.NewScanner(response.Body)
+ idleTimeout := streamIdleTimeoutFromEnv()
+ events := make(chan streamEvent, 32)
+ go readStreamEvents(streamCtx, response.Body, events)
+ var idleTimer <-chan time.Time
+ var timer *time.Timer
+ if idleTimeout > 0 {
+ timer = time.NewTimer(idleTimeout)
+ defer timer.Stop()
+ idleTimer = timer.C
+ }
+ resetIdleTimer := func() {
+ if timer == nil {
+ return
+ }
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(idleTimeout)
+ }
+ for {
+ select {
+ case event := <-events:
+ if event.err != nil {
+ return builder.String(), event.err
+ }
+ if event.done {
+ content := strings.TrimSpace(builder.String())
+ if content == "" {
+ return "", fmt.Errorf("llama.cpp stream response was empty")
+ }
+ return content, nil
+ }
+ if event.content == "" {
+ continue
+ }
+ resetIdleTimer()
+ builder.WriteString(event.content)
+ if onChunk != nil {
+ if err := onChunk(event.content); err != nil {
+ return builder.String(), err
+ }
+ }
+ case <-idleTimer:
+ cancelStream()
+ _ = response.Body.Close()
+ return builder.String(), fmt.Errorf("read llama.cpp stream idle timeout after %s", idleTimeout)
+ case <-ctx.Done():
+ cancelStream()
+ _ = response.Body.Close()
+ return builder.String(), fmt.Errorf("read llama.cpp stream context done: %w", ctx.Err())
+ }
+ }
+}
+
+type streamEvent struct {
+ content string
+ done bool
+ err error
+}
+
+func readStreamEvents(ctx context.Context, body httpBody, events chan<- streamEvent) {
+ scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.HasPrefix(line, ":") {
- continue
- }
- if !strings.HasPrefix(line, "data:") {
+ if line == "" || strings.HasPrefix(line, ":") || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
- break
+ sendStreamEvent(ctx, events, streamEvent{done: true})
+ return
}
var chunk chatCompletionStreamResponse
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
- return builder.String(), fmt.Errorf("decode llama.cpp stream chunk: %w", err)
+ sendStreamEvent(ctx, events, streamEvent{err: fmt.Errorf("decode llama.cpp stream chunk: %w", err)})
+ return
}
for _, choice := range chunk.Choices {
content := choice.Delta.Content
if content == "" {
content = choice.Message.Content
}
- if content == "" {
- continue
- }
- builder.WriteString(content)
- if onChunk != nil {
- if err := onChunk(content); err != nil {
- return builder.String(), err
- }
+ if content != "" {
+ sendStreamEvent(ctx, events, streamEvent{content: content})
}
}
}
if err := scanner.Err(); err != nil {
- return builder.String(), fmt.Errorf("read llama.cpp stream: %w", err)
+ if ctx.Err() != nil {
+ return
+ }
+ sendStreamEvent(ctx, events, streamEvent{err: fmt.Errorf("read llama.cpp stream: %w", err)})
+ return
}
- content := strings.TrimSpace(builder.String())
- if content == "" {
- return "", fmt.Errorf("llama.cpp stream response was empty")
+ sendStreamEvent(ctx, events, streamEvent{done: true})
+}
+
+type httpBody interface {
+ Read([]byte) (int, error)
+}
+
+func sendStreamEvent(ctx context.Context, events chan<- streamEvent, event streamEvent) {
+ select {
+ case events <- event:
+ case <-ctx.Done():
}
- return content, nil
}
// ListModels returns model IDs exposed by llama-server.
diff --git a/internal/infrastructure/llamacpp/client_test.go b/internal/infrastructure/llamacpp/client_test.go
index 4486fcd..a07e0af 100644
--- a/internal/infrastructure/llamacpp/client_test.go
+++ b/internal/infrastructure/llamacpp/client_test.go
@@ -106,6 +106,77 @@ func TestGenerateStreamCallsChatCompletionsAndAssemblesChunks(t *testing.T) {
}
}
+func TestGenerateStreamFallsBackAfterIdlePrimaryStream(t *testing.T) {
+ primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ <-r.Context().Done()
+ }))
+ defer primaryServer.Close()
+ fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"# Fallback\"}}]}\n\n"))
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer fallbackServer.Close()
+
+ t.Setenv("LLM_BASE_URL", primaryServer.URL+"/v1")
+ t.Setenv("LLM_MODEL", "primary")
+ t.Setenv("LLM_FALLBACK_BASE_URLS", fallbackServer.URL+"/v1")
+ t.Setenv("LLM_FALLBACK_MODELS", "fallback")
+ t.Setenv("LLM_STREAM_IDLE_TIMEOUT_MILLISECONDS", "20")
+
+ client, err := NewClientFromEnv()
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+ var chunks []string
+ draft, err := client.GenerateStream(context.Background(), "write", func(chunk string) error {
+ chunks = append(chunks, chunk)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("generate stream with fallback: %v", err)
+ }
+ if draft != "# Fallback" || strings.Join(chunks, "") != "# Fallback" {
+ t.Fatalf("unexpected fallback stream: draft=%q chunks=%q", draft, strings.Join(chunks, ""))
+ }
+}
+
+func TestGenerateStreamFallsBackWhenPrimaryStreamNeverStarts(t *testing.T) {
+ primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(200 * time.Millisecond)
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer primaryServer.Close()
+ fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"# First Byte Fallback\"}}]}\n\n"))
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer fallbackServer.Close()
+
+ t.Setenv("LLM_BASE_URL", primaryServer.URL+"/v1")
+ t.Setenv("LLM_MODEL", "primary")
+ t.Setenv("LLM_FALLBACK_BASE_URLS", fallbackServer.URL+"/v1")
+ t.Setenv("LLM_FALLBACK_MODELS", "fallback")
+ t.Setenv("LLM_STREAM_FIRST_BYTE_TIMEOUT_MILLISECONDS", "20")
+
+ client, err := NewClientFromEnv()
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+ draft, err := client.GenerateStream(context.Background(), "write", nil)
+ if err != nil {
+ t.Fatalf("generate stream with first-byte fallback: %v", err)
+ }
+ if draft != "# First Byte Fallback" {
+ t.Fatalf("unexpected fallback stream: %q", draft)
+ }
+}
+
func TestGenerateWithSystemUsesCallerPrompt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request chatCompletionRequest
From 54899901643d7b4ef5b1d2e34e5e41118a79e371 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 17:37:05 +0900
Subject: [PATCH 28/33] Implement history artifact UI cut (#82)
Closes #81
---
.gitignore | 2 +-
cmd/server/main.go | 20 +-
cmd/server/main_test.go | 34 +
...02-multi-persona-multi-format-extension.md | 19 +-
.../issue-27-28-history-artifacts-api.md | 113 ++++
.../next-implementation-cut.md | 28 +-
...ssue-27-28-history-artifacts-2026-05-03.md | 79 +++
internal/handlers/workflow.go | 295 +++++++++
internal/handlers/workflow_history_test.go | 148 +++++
.../repository/memory/workflow.go | 33 +
.../repository/sqlite/workflow.go | 156 +++++
static/css/style.css | 123 ++++
static/history_ui_test.go | 57 ++
static/index.html | 38 +-
static/js/script.js | 601 ++++++++++++++++++
15 files changed, 1727 insertions(+), 19 deletions(-)
create mode 100644 cmd/server/main_test.go
create mode 100644 docs/implementation-plans/issue-27-28-history-artifacts-api.md
create mode 100644 docs/validation/issue-27-28-history-artifacts-2026-05-03.md
create mode 100644 internal/handlers/workflow_history_test.go
create mode 100644 static/history_ui_test.go
diff --git a/.gitignore b/.gitignore
index 5dbe95b..9c277ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,7 +28,7 @@ Thumbs.db
bin/
dist/
tmp/
-server
+/server
data/
# ログ関連
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 345d7c2..0c6b261 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -24,8 +24,15 @@ func main() {
// ルーターの設定
r := mux.NewRouter()
+ registerRoutes(r)
- // 静的ファイルの配信 (staticディレクトリをルートとして提供)
+ log.Printf("Starting server on port %s...", port)
+ if err := http.ListenAndServe(":"+port, r); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func registerRoutes(r *mux.Router) {
fs := http.FileServer(http.Dir("static"))
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs))
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
@@ -39,11 +46,17 @@ func main() {
r.HandleFunc("/api/config/storage", handlers.UpdateStorageConfigHandler).Methods("PATCH")
r.HandleFunc("/api/personas", handlers.ListPersonasHandler).Methods("GET")
r.HandleFunc("/api/formats", handlers.ListFormatsHandler).Methods("GET")
+ r.HandleFunc("/api/history", handlers.ListWorkflowArtifactsHandler).Methods("GET")
+ r.HandleFunc("/api/workflow/artifacts", handlers.ListWorkflowArtifactsHandler).Methods("GET")
+ r.HandleFunc("/api/author-style", handlers.ListAuthorStylesHandler).Methods("GET")
r.HandleFunc("/api/author-style/seed", handlers.SeedAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/analyze", handlers.AnalyzeAuthorStyleHandler).Methods("POST")
r.HandleFunc("/api/author-style/{id}", handlers.GetAuthorStyleHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions/templates", handlers.GetBriefSessionTemplateHandler).Methods("GET")
+ r.HandleFunc("/api/brief-sessions", handlers.ListBriefSessionsHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions", handlers.CreateBriefSessionHandler).Methods("POST")
+ r.HandleFunc("/api/briefs", handlers.ListBriefArtifactsHandler).Methods("GET")
+ r.HandleFunc("/api/briefs/{id}", handlers.GetBriefArtifactHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions/{id}", handlers.GetBriefSessionHandler).Methods("GET")
r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST")
r.HandleFunc("/api/brief-sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST")
@@ -55,9 +68,4 @@ func main() {
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/index.html")
})
-
- log.Printf("Starting server on port %s...", port)
- if err := http.ListenAndServe(":"+port, r); err != nil {
- log.Fatal(err)
- }
}
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
new file mode 100644
index 0000000..24889ed
--- /dev/null
+++ b/cmd/server/main_test.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/gorilla/mux"
+)
+
+func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
+ router := mux.NewRouter()
+ registerRoutes(router)
+
+ for _, tt := range []struct {
+ method string
+ path string
+ }{
+ {method: http.MethodGet, path: "/api/history"},
+ {method: http.MethodGet, path: "/api/workflow/artifacts"},
+ {method: http.MethodGet, path: "/api/author-style"},
+ {method: http.MethodGet, path: "/api/brief-sessions"},
+ {method: http.MethodGet, path: "/api/briefs"},
+ {method: http.MethodGet, path: "/api/briefs/session-1"},
+ } {
+ request, err := http.NewRequest(tt.method, tt.path, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ var match mux.RouteMatch
+ if !router.Match(request, &match) {
+ t.Fatalf("%s %s did not match a route", tt.method, tt.path)
+ }
+ }
+}
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 635d58b..beef122 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -166,6 +166,15 @@ Additions:
Existing `/api/generate` remains a compatibility facade.
+Implemented history/artifact read subset as of the #27/#28 cut:
+
+- `GET /api/history` and `GET /api/workflow/artifacts` — combined reusable workflow artifact index with `style_guides`, `sessions`, and `briefs`.
+- `GET /api/author-style` — saved writing style-guide list for picker UIs.
+- `GET /api/brief-sessions` — saved interview session summaries.
+- `GET /api/briefs` and `GET /api/briefs/{id}` — completed brief artifacts for readable card rendering and reuse.
+
+These endpoints are intentionally narrower than the future project/article/draft artifact surface described above. They do not implement add-persona authoring UI, project/article browsing, draft version browsing, or broader edit persistence semantics.
+
## Testing Strategy
- Unit tests added per format validator, per persona seed, per fetcher.
@@ -212,6 +221,7 @@ Current implementation status as of 2026-05-03:
- Phase B2/B3/B4 are implemented: historical source acquisition works for note, Zenn, Qiita, Cor RSS, and Cor GitHub Markdown; all five formats have prompt fragments, embedded guides, and validators; `terisuke` and `cloudia` ship as distinct seed personas. Validation is recorded in [Issue 22 source fetcher validation](../validation/issue-22-source-fetchers-2026-05-02.md) and [Issue 23/24 format and persona seed validation](../validation/issue-23-24-format-persona-seed-2026-05-02.md).
- Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)).
- Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)).
+- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
- Phase D1 is implemented and merged: handler tests now cover template selection, edit/fork errors, SSE follow-up and draft paths, completed-session draft fallback, regenerate-section context recovery, Analyze/Generate compatibility handlers, and SQLite driver selection. `go test ./internal/handlers -cover` reports 80%+ statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)).
- Runtime runner support is implemented and merged: `cmd/scenario/live_media_matrix` reads the offline matrix, emits planned aggregate JSON/Markdown by default, and executes live Evo X2 draft runs only when `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` is used ([#57](https://github.com/terisuke/note_maker/issues/57)).
- The 2026-05-03 browser 500 analysis showed an implementation drift: plain web-app startup still defaulted to workstation-local `127.0.0.1:8081`, while this ADR requires Evo X2 Tailnet as primary. Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the default order to Evo X2 Ollama over Tailnet → Evo X2 llama.cpp → workstation-local llama.cpp and makes the UI show the actual endpoint/model reported by SSE.
@@ -225,8 +235,9 @@ Current implementation status as of 2026-05-03:
Near-term execution order:
1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check.
-2. Continue Phase C2/C3 ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)) and Browser E2E ([#13](https://github.com/terisuke/note_maker/issues/13)) in parallel; they are product-readiness work.
-3. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate.
+2. Land the #27/#28 history/artifact read cut and add Browser E2E coverage ([#13](https://github.com/terisuke/note_maker/issues/13)) for history opening, readable cards, and the existing edit/fork/stream/regenerate flows.
+3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, and richer project/article/draft history surfaces from the #26 SQLite schema.
+4. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate.
## Tracked issues
@@ -242,8 +253,8 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- B4 — [#24](https://github.com/terisuke/note_maker/issues/24) Seed persona library with `terisuke` and `cloudia` profiles. Implemented for the built-in registry: seeds include sources, default formats, and voice notes; live source re-analysis remains under [#22](https://github.com/terisuke/note_maker/issues/22).
- B5 — [#25](https://github.com/terisuke/note_maker/issues/25) Format- and persona-aware fixed question sets
- C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store.
-- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI
-- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards
+- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — implemented in the current cut for saved style-guide and brief-session reuse through `履歴から再開`, backed by `GET /api/workflow/artifacts`, `GET /api/author-style`, and `GET /api/brief-sessions`. Add-persona authoring UI and broader edit-persistence expectations remain follow-up work.
+- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Rich project/article/draft artifact browsing remains a later Phase C layer on top of the #26 SQLite schema.
- D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` — implemented in the current cut with 80.0% handler package coverage.
- Runtime runner — [#57](https://github.com/terisuke/note_maker/issues/57) Add live LLM media-matrix runner and aggregate evaluator, feeding [#40](https://github.com/terisuke/note_maker/issues/40) — implemented in the current cut.
- Runtime stabilization epic — [#40](https://github.com/terisuke/note_maker/issues/40) Stabilize Tailnet Evo X2 draft quality and runtime metrics. #70-#73 provide the prerequisite validation and diagnostics. [#74](https://github.com/terisuke/note_maker/issues/74) has passed the bounded Cloudia/Zenn and Cloudia/Qiita proofs plus the final `5/5` publishing-target matrix.
diff --git a/docs/implementation-plans/issue-27-28-history-artifacts-api.md b/docs/implementation-plans/issue-27-28-history-artifacts-api.md
new file mode 100644
index 0000000..30991ed
--- /dev/null
+++ b/docs/implementation-plans/issue-27-28-history-artifacts-api.md
@@ -0,0 +1,113 @@
+# Issue 27/28 History And Artifact API Cut
+
+Date: 2026-05-03
+Branch: `codex/issue27-28-history-artifacts`
+
+## Purpose
+
+This cut makes persisted workflow memory visible enough for day-to-day drafting:
+
+- reuse a saved writing style guide without re-fetching sources,
+- reopen a saved interview session,
+- read completed briefs as cards instead of raw JSON,
+- keep the raw Markdown/JSON available for audit.
+
+It is intentionally smaller than the full Phase C vision in ADR 0002. Project/article/draft browsing is left as a follow-up on top of the richer SQLite schema.
+
+## Implemented Store Surface
+
+The handler-facing `workflowStoreBackend` now exposes the current reusable workflow artifacts:
+
+```go
+ListAuthorStyles() ([]authorstyle.AnalyzeResult, error)
+ListSessions() ([]brief.ArticleBriefSession, error)
+ListBriefs() (map[string]brief.ArticleBrief, error)
+```
+
+Both the JSON/memory store and SQLite store implement these methods.
+
+SQLite also gained entry-point list methods for later project history work:
+
+```go
+ListProjects() ([]ProjectRecord, error)
+ListArticlesByProject(projectID string) ([]ArticleRecord, error)
+```
+
+Those SQLite methods are not yet surfaced in the web UI.
+
+## Implemented HTTP Surface
+
+The web UI uses the combined index first:
+
+- `GET /api/workflow/artifacts`
+- `GET /api/history` as an alias
+
+Response:
+
+```json
+{
+ "style_guides": [],
+ "sessions": [],
+ "briefs": []
+}
+```
+
+Focused read endpoints are also available:
+
+- `GET /api/author-style` - saved style-guide artifacts
+- `GET /api/author-style/{id}` - existing detail endpoint by analysis/profile/guide id
+- `GET /api/brief-sessions` - saved interview session summaries
+- `GET /api/brief-sessions/{id}` - existing session detail endpoint
+- `GET /api/briefs` - completed brief artifacts
+- `GET /api/briefs/{id}` - completed brief artifact by session id
+
+## Implemented UI Surface
+
+`static/index.html` now includes a `履歴から再開` area:
+
+- persona filter for history selection,
+- saved style-guide picker,
+- saved interview-session picker,
+- refresh/open/clear controls,
+- loading, empty, and error states.
+
+`static/js/script.js` restores selected history into the existing workflow state:
+
+- selected style guide sets `profileId`, guide metadata, and style card,
+- selected session restores `sessionId`, transcript state, completed brief, persona/format mode, and draft-generation readiness,
+- selecting only a session resolves its style guide via `style_profile_id`.
+
+## Human-Readable Artifacts
+
+The UI now renders:
+
+- style guide cards with profile/guide/article metadata and parsed Markdown sections,
+- article brief cards with theme, reader, opening episode, action, must-include, context, exclusions, structure, tone, custom answers, and deep-dive answers.
+
+Raw Markdown and JSON are still available behind disclosure controls. This keeps review/debug data accessible without making it the default user experience.
+
+## Tests
+
+Added:
+
+- `internal/handlers/workflow_history_test.go`
+- `cmd/server/main_test.go`
+- `static/history_ui_test.go`
+
+Validation commands:
+
+```sh
+node --check static/js/script.js
+go test ./...
+git diff --check
+```
+
+## Remaining Phase C Work
+
+Not included in this cut:
+
+- add-persona authoring UI,
+- edit persistence beyond the existing fork-on-edit/session/brief save path,
+- project/article/draft history browsing,
+- draft version and section-regeneration artifact browsing,
+- browser E2E for the new history flow under Issue #13.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 0beece0..6502df0 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -25,10 +25,18 @@ Implemented and merged:
- [#61](https://github.com/terisuke/note_maker/issues/61) / [PR #62](https://github.com/terisuke/note_maker/pull/62) — workflow storage mode can be inspected and switched from the settings UI; environment-locked deployments remain read-only.
- [#70](https://github.com/terisuke/note_maker/issues/70)-[#73](https://github.com/terisuke/note_maker/issues/73) prerequisite slice for runtime evaluation — interview-template coverage, failed draft artifact preservation, bounded format repair, and output-format-specific scenario gates are in place for staged #74 reruns.
+Implemented in the current #27/#28 history/artifact cut:
+
+- [#27](https://github.com/terisuke/note_maker/issues/27) — first saved-history UI cut: `履歴から再開`, persona-scoped saved style-guide and brief-session selectors, and open/clear/refresh controls.
+- [#28](https://github.com/terisuke/note_maker/issues/28) — first readable artifact cut: style-guide cards and article-brief cards replace raw-only `pre` output while keeping Markdown/JSON details available.
+- History read API surface — `GET /api/history`, `GET /api/workflow/artifacts`, `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`.
+- Store list support — memory and SQLite now expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also has `ListProjects` and `ListArticlesByProject` for the richer #26 schema.
+- Focused tests — `internal/handlers/workflow_history_test.go` covers saved artifact responses and brief detail errors; `static/history_ui_test.go` locks the frontend contract.
+- Validation — [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
+
Open and active:
- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
-- History UI and readable artifacts: [#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28).
- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40), now satisfied for the current note/Qiita/Zenn/Cor blog publishing-target acceptance scope by the 2026-05-03 full Tailnet Evo X2 matrix.
- Runtime evaluation sub-issue [#74](https://github.com/terisuke/note_maker/issues/74), satisfied by the staged reruns and the final `5/5` full matrix pass.
@@ -38,6 +46,12 @@ Open and active:
- Interview usability fixed before measurement: [#66](https://github.com/terisuke/note_maker/issues/66), with details in [Issue 66 plain brief questions validation](../validation/issue-66-plain-brief-questions-2026-05-03.md).
- Style-source switching fixed before measurement: [#68](https://github.com/terisuke/note_maker/issues/68), with details in [Issue 68 media-aware style source validation](../validation/issue-68-media-aware-style-source-2026-05-03.md).
+Remaining Phase C gaps after the current #27/#28 cut:
+
+- Add-persona authoring UI is not implemented; the current UI consumes seeded personas and saved artifacts.
+- Broader edit persistence called out in the issue text is not implemented beyond the existing fork-on-edit/session/brief save paths.
+- Project/article/draft artifact browsing from SQLite's normalized #26 schema is not exposed in the web UI yet; this cut intentionally uses style guides, sessions, and completed briefs as the reusable history surface.
+
## Final evaluation target
The final integrated evaluation should use `cmd/scenario/media_matrix` as the input matrix, then run live Evo X2 Tailnet draft scenarios for:
@@ -109,13 +123,15 @@ Use subagents with disjoint write scopes when implementation resumes:
| Lane | Issue | Subagent role | Write scope | Done when |
|---|---|---|---|---|
| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
-| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | `static/*`, read APIs for projects/sessions/drafts once exposed | persona/session picker and human-readable brief/style cards use persisted state |
-| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
+| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | done for this cut | style-guide/session history picker and readable brief/style cards use persisted workflow state |
+| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, history open, readable cards, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
+| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing are split from the #27/#28 first cut |
Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts.
-2. Continue #27/#28 and #13 in parallel as product-readiness work: history picker, readable brief/style cards, and browser E2E coverage are now the highest-value next tasks.
-3. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate.
+1. Land the current #27/#28 history/artifact cut with its validation doc, then wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming, and regenerate-section coverage goals.
+2. Split the remaining Phase C work into explicit follow-up issues before implementation: add-persona authoring UI, broader edit persistence semantics, and project/article/draft artifact browsing from the #26 SQLite schema.
+3. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts.
+4. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate.
diff --git a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
new file mode 100644
index 0000000..67b27be
--- /dev/null
+++ b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
@@ -0,0 +1,79 @@
+# Issue 27/28 history and artifact UI/API validation
+
+Date: 2026-05-03
+Branch: `codex/issue27-28-history-artifacts`
+
+## Scope
+
+This validation covers:
+
+- [#27](https://github.com/terisuke/note_maker/issues/27) first saved-history picker cut.
+- [#28](https://github.com/terisuke/note_maker/issues/28) first human-readable style-guide and brief artifact card cut.
+
+It deliberately does not claim completion for add-persona authoring UI, broader edit persistence, project/article/draft history browsing, draft version browsing, or Browser E2E coverage.
+
+## What changed
+
+- Added history read routes:
+ - `GET /api/history`
+ - `GET /api/workflow/artifacts`
+ - `GET /api/author-style`
+ - `GET /api/brief-sessions`
+ - `GET /api/briefs`
+ - `GET /api/briefs/{id}`
+- Added store list methods:
+ - memory: `ListAuthorStyles`, `ListSessions`, `ListBriefs`
+ - SQLite: `ListAuthorStyles`, `ListSessions`, `ListBriefs`, `ListProjects`, `ListArticlesByProject`
+- Added the web UI `履歴から再開` section with saved style-guide/session pickers.
+- Added readable style-guide and article-brief cards while keeping raw Markdown/JSON disclosures.
+- Added focused handler and static UI contract tests.
+
+## API Contract
+
+`GET /api/workflow/artifacts` and `GET /api/history` return one reusable index:
+
+```json
+{
+ "style_guides": [],
+ "sessions": [],
+ "briefs": []
+}
+```
+
+The UI then opens details through existing or focused endpoints:
+
+- style guide detail: `GET /api/author-style/{id}`
+- session detail: `GET /api/brief-sessions/{id}`
+- brief detail: `GET /api/briefs/{id}`
+
+## Verification
+
+Command:
+
+```sh
+go test ./internal/handlers ./static
+```
+
+Result:
+
+```text
+ok github.com/teradakousuke/note_maker/internal/handlers (cached)
+ok github.com/teradakousuke/note_maker/static (cached)
+```
+
+## Acceptance Status
+
+- Saved style guides can be listed for picker UIs: done.
+- Saved interview sessions can be listed with completion and brief availability metadata: done.
+- Completed briefs can be listed and retrieved by session id: done.
+- Combined workflow artifact index returns style guides, sessions, and briefs: done.
+- UI includes `履歴から再開`, refresh/open/clear controls, saved style/session selects, and status messaging: done.
+- Style guide is rendered as a readable card and raw Markdown remains available: done.
+- Article brief is rendered as a readable card and raw JSON remains available: done.
+
+## Remaining Work
+
+- Add-persona authoring UI is still unimplemented.
+- Broader edit persistence beyond the existing fork-on-edit/session save flow is still unimplemented.
+- Project/article/draft history browsing from SQLite remains unimplemented in the UI.
+- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13).
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index a99f51c..ef1d278 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "sort"
"strings"
"sync"
"time"
@@ -31,10 +32,13 @@ var workflowStore = newWorkflowStore()
type workflowStoreBackend interface {
SaveAuthorStyle(authorstyleapp.AnalyzeResult) error
GetAuthorStyle(string) (authorstyleapp.AnalyzeResult, bool)
+ ListAuthorStyles() ([]authorstyleapp.AnalyzeResult, error)
SaveSession(briefdomain.ArticleBriefSession) error
GetSession(string) (briefdomain.ArticleBriefSession, bool)
+ ListSessions() ([]briefdomain.ArticleBriefSession, error)
SaveBrief(string, briefdomain.ArticleBrief) error
GetBrief(string) (briefdomain.ArticleBrief, bool)
+ ListBriefs() (map[string]briefdomain.ArticleBrief, error)
GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool)
}
@@ -78,11 +82,31 @@ type authorStyleResponse struct {
GuideID string `json:"guide_id"`
GuideMarkdown string `json:"guide_markdown"`
ArticleCount int `json:"article_count"`
+ CreatedAt string `json:"created_at,omitempty"`
Source any `json:"source"`
Profile any `json:"profile"`
Guide any `json:"guide"`
}
+type authorStyleListResponse struct {
+ StyleGuides []styleGuideArtifactResponse `json:"style_guides"`
+}
+
+type styleGuideArtifactResponse struct {
+ ID string `json:"id"`
+ AnalysisID string `json:"analysis_id"`
+ ProfileID string `json:"profile_id"`
+ GuideID string `json:"guide_id"`
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ ArticleCount int `json:"article_count"`
+ GuideMarkdown string `json:"guide_markdown"`
+ Source authordomain.AuthorSource `json:"source"`
+ Profile authordomain.AuthorStyleProfile `json:"profile"`
+ Guide authordomain.WritingStyleGuide `json:"guide"`
+}
+
type createBriefSessionRequest struct {
StyleProfileID string `json:"style_profile_id"`
SessionID string `json:"session_id"`
@@ -116,6 +140,47 @@ type briefSessionResponse struct {
Answers []briefdomain.BriefAnswer `json:"answers"`
}
+type briefSessionListResponse struct {
+ Sessions []briefSessionSummaryResponse `json:"sessions"`
+}
+
+type briefSessionSummaryResponse struct {
+ SessionID string `json:"session_id"`
+ StyleProfileID string `json:"style_profile_id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ ParentSessionID string `json:"parent_session_id,omitempty"`
+ Phase string `json:"phase"`
+ Completed bool `json:"completed"`
+ AnswerCount int `json:"answer_count"`
+ QuestionCount int `json:"question_count"`
+ BriefAvailable bool `json:"brief_available"`
+ Title string `json:"title,omitempty"`
+}
+
+type briefArtifactListResponse struct {
+ Briefs []briefArtifactResponse `json:"briefs"`
+}
+
+type briefArtifactResponse struct {
+ SessionID string `json:"session_id"`
+ StyleProfileID string `json:"style_profile_id"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ ParentSessionID string `json:"parent_session_id,omitempty"`
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ AnswerCount int `json:"answer_count"`
+ DeepDiveCount int `json:"deep_dive_count"`
+ Brief briefdomain.ArticleBrief `json:"brief"`
+}
+
+type workflowArtifactsResponse struct {
+ StyleGuides []styleGuideArtifactResponse `json:"style_guides"`
+ Sessions []briefSessionSummaryResponse `json:"sessions"`
+ Briefs []briefArtifactResponse `json:"briefs"`
+}
+
type briefSessionTemplateResponse struct {
PersonaID string `json:"persona_id"`
OutputFormatID string `json:"output_format_id"`
@@ -615,6 +680,19 @@ func buildPresetAuthorStyle(persona personadomain.Persona, format outputformat.O
}, nil
}
+// ListAuthorStylesHandler returns stored style-guide artifacts for picker UIs.
+func ListAuthorStylesHandler(w http.ResponseWriter, r *http.Request) {
+ results, err := workflowStore.ListAuthorStyles()
+ if err != nil {
+ respondWithError(w, "AUTHOR_STYLE_LIST_FAILED", "Failed to list author styles", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sortAuthorStyles(results)
+ respondWithJSON(w, http.StatusOK, authorStyleListResponse{
+ StyleGuides: toStyleGuideArtifactResponses(results),
+ })
+}
+
// GetAuthorStyleHandler returns a stored author style analysis result.
func GetAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
id := pathValue(r, "id")
@@ -626,6 +704,24 @@ func GetAuthorStyleHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, toAuthorStyleResponse(result))
}
+// ListBriefSessionsHandler returns saved interview sessions for project history UIs.
+func ListBriefSessionsHandler(w http.ResponseWriter, r *http.Request) {
+ sessions, err := workflowStore.ListSessions()
+ if err != nil {
+ respondWithError(w, "BRIEF_SESSION_LIST_FAILED", "Failed to list brief sessions", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ briefs, err := workflowStore.ListBriefs()
+ if err != nil {
+ respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sortBriefSessions(sessions)
+ respondWithJSON(w, http.StatusOK, briefSessionListResponse{
+ Sessions: toBriefSessionSummaryResponses(sessions, briefs),
+ })
+}
+
// CreateBriefSessionHandler starts the fixed-question interview.
func CreateBriefSessionHandler(w http.ResponseWriter, r *http.Request) {
var req createBriefSessionRequest
@@ -694,6 +790,56 @@ func GetBriefSessionHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, toBriefSessionResponse(result))
}
+// ListBriefArtifactsHandler returns completed brief artifacts for reuse.
+func ListBriefArtifactsHandler(w http.ResponseWriter, r *http.Request) {
+ briefs, err := workflowStore.ListBriefs()
+ if err != nil {
+ respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, briefArtifactListResponse{
+ Briefs: listBriefArtifactResponses(briefs),
+ })
+}
+
+// GetBriefArtifactHandler returns one completed brief artifact by session ID.
+func GetBriefArtifactHandler(w http.ResponseWriter, r *http.Request) {
+ sessionID := pathValue(r, "id")
+ articleBrief, ok := workflowStore.GetBrief(sessionID)
+ if !ok {
+ respondWithError(w, "BRIEF_NOT_FOUND", "Brief was not found", sessionID, http.StatusNotFound)
+ return
+ }
+ session, sessionOK := workflowStore.GetSession(sessionID)
+ respondWithJSON(w, http.StatusOK, toBriefArtifactResponse(sessionID, articleBrief, session, sessionOK))
+}
+
+// ListWorkflowArtifactsHandler returns all currently reusable workflow artifacts.
+func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) {
+ styles, err := workflowStore.ListAuthorStyles()
+ if err != nil {
+ respondWithError(w, "AUTHOR_STYLE_LIST_FAILED", "Failed to list author styles", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ briefs, err := workflowStore.ListBriefs()
+ if err != nil {
+ respondWithError(w, "BRIEF_LIST_FAILED", "Failed to list completed briefs", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sessions, err := workflowStore.ListSessions()
+ if err != nil {
+ respondWithError(w, "BRIEF_SESSION_LIST_FAILED", "Failed to list brief sessions", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sortAuthorStyles(styles)
+ sortBriefSessions(sessions)
+ respondWithJSON(w, http.StatusOK, workflowArtifactsResponse{
+ StyleGuides: toStyleGuideArtifactResponses(styles),
+ Sessions: toBriefSessionSummaryResponses(sessions, briefs),
+ Briefs: listBriefArtifactResponses(briefs),
+ })
+}
+
// EditBriefAnswerHandler creates a new child session from an edited past answer.
func EditBriefAnswerHandler(w http.ResponseWriter, r *http.Request) {
var req editBriefAnswerRequest
@@ -1156,12 +1302,77 @@ func toAuthorStyleResponse(result authorstyleapp.AnalyzeResult) authorStyleRespo
GuideID: result.Guide.ID,
GuideMarkdown: result.Guide.Markdown,
ArticleCount: result.ArticleCount,
+ CreatedAt: formatOptionalTime(result.CreatedAt),
+ Source: result.Source,
+ Profile: result.Profile,
+ Guide: result.Guide,
+ }
+}
+
+func toStyleGuideArtifactResponses(results []authorstyleapp.AnalyzeResult) []styleGuideArtifactResponse {
+ items := make([]styleGuideArtifactResponse, 0, len(results))
+ for _, result := range results {
+ items = append(items, toStyleGuideArtifactResponse(result))
+ }
+ return items
+}
+
+func toStyleGuideArtifactResponse(result authorstyleapp.AnalyzeResult) styleGuideArtifactResponse {
+ return styleGuideArtifactResponse{
+ ID: result.Guide.ID,
+ AnalysisID: result.ID,
+ ProfileID: result.Profile.ID,
+ GuideID: result.Guide.ID,
+ Title: styleGuideTitle(result),
+ Description: styleGuideDescription(result),
+ CreatedAt: formatOptionalTime(result.CreatedAt),
+ ArticleCount: result.ArticleCount,
+ GuideMarkdown: result.Guide.Markdown,
Source: result.Source,
Profile: result.Profile,
Guide: result.Guide,
}
}
+func sortAuthorStyles(results []authorstyleapp.AnalyzeResult) {
+ sort.SliceStable(results, func(i, j int) bool {
+ left := results[i].CreatedAt
+ right := results[j].CreatedAt
+ if !left.Equal(right) {
+ return left.After(right)
+ }
+ return results[i].ID < results[j].ID
+ })
+}
+
+func styleGuideTitle(result authorstyleapp.AnalyzeResult) string {
+ if username := strings.TrimSpace(result.Source.Username); username != "" {
+ return username
+ }
+ if len(result.Source.Articles) > 0 {
+ if title := strings.TrimSpace(result.Source.Articles[0].Title); title != "" {
+ return title
+ }
+ }
+ return firstNonEmpty(result.Profile.ID, result.Guide.ID, result.ID)
+}
+
+func styleGuideDescription(result authorstyleapp.AnalyzeResult) string {
+ if len(result.Source.Articles) == 0 {
+ return ""
+ }
+ titles := make([]string, 0, len(result.Source.Articles))
+ for _, article := range result.Source.Articles {
+ if title := strings.TrimSpace(article.Title); title != "" {
+ titles = append(titles, title)
+ }
+ if len(titles) == 3 {
+ break
+ }
+ }
+ return strings.Join(titles, " / ")
+}
+
func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionResponse {
var question *articleQuestionJSON
if result.NextQuestion != nil {
@@ -1182,6 +1393,83 @@ func toBriefSessionResponse(result briefapp.InterviewResult) briefSessionRespons
}
}
+func toBriefSessionSummaryResponses(sessions []briefdomain.ArticleBriefSession, briefs map[string]briefdomain.ArticleBrief) []briefSessionSummaryResponse {
+ items := make([]briefSessionSummaryResponse, 0, len(sessions))
+ for _, session := range sessions {
+ articleBrief, briefAvailable := briefs[session.ID]
+ items = append(items, toBriefSessionSummaryResponse(session, articleBrief, briefAvailable))
+ }
+ return items
+}
+
+func toBriefSessionSummaryResponse(session briefdomain.ArticleBriefSession, articleBrief briefdomain.ArticleBrief, briefAvailable bool) briefSessionSummaryResponse {
+ return briefSessionSummaryResponse{
+ SessionID: session.ID,
+ StyleProfileID: session.StyleProfileID,
+ PersonaID: session.PersonaID,
+ OutputFormatID: session.OutputFormatID,
+ ParentSessionID: session.ParentSessionID,
+ Phase: string(session.Phase),
+ Completed: session.Completed,
+ AnswerCount: len(session.Answers),
+ QuestionCount: len(session.Questions),
+ BriefAvailable: briefAvailable,
+ Title: briefTitle(session.ID, articleBrief),
+ }
+}
+
+func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) {
+ sort.SliceStable(sessions, func(i, j int) bool {
+ if sessions[i].Completed != sessions[j].Completed {
+ return sessions[i].Completed
+ }
+ return sessions[i].ID < sessions[j].ID
+ })
+}
+
+func listBriefArtifactResponses(briefs map[string]briefdomain.ArticleBrief) []briefArtifactResponse {
+ sessionIDs := make([]string, 0, len(briefs))
+ for sessionID := range briefs {
+ sessionIDs = append(sessionIDs, sessionID)
+ }
+ sort.Strings(sessionIDs)
+ items := make([]briefArtifactResponse, 0, len(sessionIDs))
+ for _, sessionID := range sessionIDs {
+ session, sessionOK := workflowStore.GetSession(sessionID)
+ items = append(items, toBriefArtifactResponse(sessionID, briefs[sessionID], session, sessionOK))
+ }
+ return items
+}
+
+func toBriefArtifactResponse(sessionID string, articleBrief briefdomain.ArticleBrief, session briefdomain.ArticleBriefSession, hasSession bool) briefArtifactResponse {
+ answerCount := 0
+ parentSessionID := ""
+ if hasSession {
+ answerCount = len(session.Answers)
+ parentSessionID = session.ParentSessionID
+ }
+ return briefArtifactResponse{
+ SessionID: sessionID,
+ StyleProfileID: articleBrief.StyleProfileID,
+ PersonaID: articleBrief.PersonaID,
+ OutputFormatID: articleBrief.OutputFormatID,
+ ParentSessionID: parentSessionID,
+ Title: briefTitle(sessionID, articleBrief),
+ Description: briefDescription(articleBrief),
+ AnswerCount: answerCount,
+ DeepDiveCount: len(articleBrief.DeepDives),
+ Brief: articleBrief,
+ }
+}
+
+func briefTitle(sessionID string, articleBrief briefdomain.ArticleBrief) string {
+ return firstNonEmpty(articleBrief.Theme, articleBrief.OpeningEpisode, articleBrief.Reader, sessionID)
+}
+
+func briefDescription(articleBrief briefdomain.ArticleBrief) string {
+ return firstNonEmpty(articleBrief.ExpectedReaderAction, articleBrief.MustInclude, articleBrief.PersonalContext)
+}
+
func toArticleQuestionJSONList(questions []briefdomain.ArticleQuestion) []articleQuestionJSON {
result := make([]articleQuestionJSON, 0, len(questions))
for _, question := range questions {
@@ -1201,6 +1489,13 @@ func toArticleQuestionJSON(question briefdomain.ArticleQuestion) articleQuestion
}
}
+func formatOptionalTime(value time.Time) string {
+ if value.IsZero() {
+ return ""
+ }
+ return value.UTC().Format(time.RFC3339)
+}
+
func newID(prefix string) string {
var bytes [6]byte
if _, err := rand.Read(bytes[:]); err != nil {
diff --git a/internal/handlers/workflow_history_test.go b/internal/handlers/workflow_history_test.go
new file mode 100644
index 0000000..43497bb
--- /dev/null
+++ b/internal/handlers/workflow_history_test.go
@@ -0,0 +1,148 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gorilla/mux"
+ briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief"
+ "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+)
+
+func TestWorkflowHistoryHandlersReturnSavedArtifacts(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ completed := sessionWithFixedAnswers(t, "session-completed-history", style.Profile.ID)
+ completed.MarkDeepDiveSkipped()
+ brief, err := completed.Complete()
+ if err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+ if err := workflowStore.SaveSession(completed); err != nil {
+ t.Fatalf("save completed session: %v", err)
+ }
+ if err := workflowStore.SaveBrief(completed.ID, brief); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+ open, err := briefdomain.NewArticleBriefSession("session-open-history", style.Profile.ID)
+ if err != nil {
+ t.Fatalf("new open session: %v", err)
+ }
+ if err := workflowStore.SaveSession(open); err != nil {
+ t.Fatalf("save open session: %v", err)
+ }
+
+ t.Run("author styles", func(t *testing.T) {
+ response := httptest.NewRecorder()
+ ListAuthorStylesHandler(response, httptest.NewRequest(http.MethodGet, "/api/author-style", nil))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload authorStyleListResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.StyleGuides) != 1 || payload.StyleGuides[0].ProfileID != style.Profile.ID || payload.StyleGuides[0].GuideMarkdown == "" {
+ t.Fatalf("unexpected style guide list: %#v", payload)
+ }
+ })
+
+ t.Run("sessions", func(t *testing.T) {
+ response := httptest.NewRecorder()
+ ListBriefSessionsHandler(response, httptest.NewRequest(http.MethodGet, "/api/brief-sessions", nil))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefSessionListResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.Sessions) != 2 {
+ t.Fatalf("sessions = %d, want 2: %#v", len(payload.Sessions), payload)
+ }
+ var completedSummary briefSessionSummaryResponse
+ for _, item := range payload.Sessions {
+ if item.SessionID == completed.ID {
+ completedSummary = item
+ }
+ }
+ if !completedSummary.Completed || !completedSummary.BriefAvailable || completedSummary.Title != brief.Theme {
+ t.Fatalf("unexpected completed session summary: %#v", completedSummary)
+ }
+ })
+
+ t.Run("briefs", func(t *testing.T) {
+ response := httptest.NewRecorder()
+ ListBriefArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/briefs", nil))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefArtifactListResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.Briefs) != 1 || payload.Briefs[0].SessionID != completed.ID || payload.Briefs[0].Brief.Theme != brief.Theme {
+ t.Fatalf("unexpected brief list: %#v", payload)
+ }
+ })
+
+ t.Run("workflow artifacts", func(t *testing.T) {
+ response := httptest.NewRecorder()
+ ListWorkflowArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/workflow/artifacts", nil))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload workflowArtifactsResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.StyleGuides) != 1 || len(payload.Sessions) != 2 || len(payload.Briefs) != 1 {
+ t.Fatalf("unexpected workflow artifacts: %#v", payload)
+ }
+ })
+}
+
+func TestGetBriefArtifactHandler(t *testing.T) {
+ style := setupWorkflowStyle(t)
+ session := sessionWithFixedAnswers(t, "session-brief-detail", style.Profile.ID)
+ session.MarkDeepDiveSkipped()
+ brief, err := session.Complete()
+ if err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ if err := workflowStore.SaveBrief(session.ID, brief); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/api/briefs/session-brief-detail", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": session.ID})
+ response := httptest.NewRecorder()
+
+ GetBriefArtifactHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload briefArtifactResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.SessionID != session.ID || payload.Title != brief.Theme || payload.AnswerCount != len(session.Answers) {
+ t.Fatalf("unexpected brief artifact: %#v", payload)
+ }
+}
+
+func TestGetBriefArtifactHandlerNotFound(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ request := httptest.NewRequest(http.MethodGet, "/api/briefs/missing", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ GetBriefArtifactHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusNotFound, "BRIEF_NOT_FOUND")
+}
diff --git a/internal/infrastructure/repository/memory/workflow.go b/internal/infrastructure/repository/memory/workflow.go
index d06f87d..e59c36d 100644
--- a/internal/infrastructure/repository/memory/workflow.go
+++ b/internal/infrastructure/repository/memory/workflow.go
@@ -90,6 +90,17 @@ func (s *WorkflowStore) GetAuthorStyle(id string) (authorstyle.AnalyzeResult, bo
return authorstyle.AnalyzeResult{}, false
}
+// ListAuthorStyles returns all stored author style analyses.
+func (s *WorkflowStore) ListAuthorStyles() ([]authorstyle.AnalyzeResult, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ results := make([]authorstyle.AnalyzeResult, 0, len(s.authorStyles))
+ for _, result := range s.authorStyles {
+ results = append(results, result)
+ }
+ return results, nil
+}
+
// SaveSession stores a brief interview session.
func (s *WorkflowStore) SaveSession(session briefdomain.ArticleBriefSession) error {
if session.ID == "" {
@@ -109,6 +120,17 @@ func (s *WorkflowStore) GetSession(id string) (briefdomain.ArticleBriefSession,
return session, ok
}
+// ListSessions returns all stored brief interview sessions.
+func (s *WorkflowStore) ListSessions() ([]briefdomain.ArticleBriefSession, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ sessions := make([]briefdomain.ArticleBriefSession, 0, len(s.sessions))
+ for _, session := range s.sessions {
+ sessions = append(sessions, session)
+ }
+ return sessions, nil
+}
+
// SaveBrief stores the completed brief for a session.
func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBrief) error {
if sessionID == "" {
@@ -131,6 +153,17 @@ func (s *WorkflowStore) GetBrief(sessionID string) (briefdomain.ArticleBrief, bo
return brief, ok
}
+// ListBriefs returns all stored completed briefs by session id.
+func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ briefs := make(map[string]briefdomain.ArticleBrief, len(s.briefs))
+ for sessionID, brief := range s.briefs {
+ briefs[sessionID] = brief
+ }
+ return briefs, nil
+}
+
// GetProfileAndGuide returns style assets by profile, guide, or analysis ID.
func (s *WorkflowStore) GetProfileAndGuide(id string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) {
result, ok := s.GetAuthorStyle(id)
diff --git a/internal/infrastructure/repository/sqlite/workflow.go b/internal/infrastructure/repository/sqlite/workflow.go
index e20005c..b74912e 100644
--- a/internal/infrastructure/repository/sqlite/workflow.go
+++ b/internal/infrastructure/repository/sqlite/workflow.go
@@ -308,6 +308,41 @@ LIMIT 1`, id, id, id, id).Scan(&result.ID, &sourceJSON, &profileJSON, &guideJSON
return result, true
}
+// ListAuthorStyles returns all stored author style analyses in newest-first order.
+func (s *WorkflowStore) ListAuthorStyles() ([]authorstyleapp.AnalyzeResult, error) {
+ rows, err := s.db.Query(`
+SELECT id, source_json, profile_json, guide_json, article_count, created_at
+FROM author_style_results
+ORDER BY created_at DESC, id`)
+ if err != nil {
+ return nil, fmt.Errorf("list author styles: %w", err)
+ }
+ defer rows.Close()
+ var results []authorstyleapp.AnalyzeResult
+ for rows.Next() {
+ var result authorstyleapp.AnalyzeResult
+ var sourceJSON, profileJSON, guideJSON, createdAt string
+ if err := rows.Scan(&result.ID, &sourceJSON, &profileJSON, &guideJSON, &result.ArticleCount, &createdAt); err != nil {
+ return nil, fmt.Errorf("scan author style: %w", err)
+ }
+ if err := unmarshalString(sourceJSON, &result.Source); err != nil {
+ return nil, fmt.Errorf("decode author source %q: %w", result.ID, err)
+ }
+ if err := unmarshalString(profileJSON, &result.Profile); err != nil {
+ return nil, fmt.Errorf("decode author profile %q: %w", result.ID, err)
+ }
+ if err := unmarshalString(guideJSON, &result.Guide); err != nil {
+ return nil, fmt.Errorf("decode writing style guide %q: %w", result.ID, err)
+ }
+ result.CreatedAt = parseTime(createdAt)
+ results = append(results, result)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate author styles: %w", err)
+ }
+ return results, nil
+}
+
// GetProfileAndGuide returns style assets by profile, guide, or analysis ID.
func (s *WorkflowStore) GetProfileAndGuide(id string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool) {
result, ok := s.GetAuthorStyle(id)
@@ -413,6 +448,38 @@ WHERE id = ?`, id).Scan(&session.ID, &session.StyleProfileID, &session.PersonaID
return session, true
}
+// ListSessions returns all stored brief interview sessions in newest-first order.
+func (s *WorkflowStore) ListSessions() ([]briefdomain.ArticleBriefSession, error) {
+ rows, err := s.db.Query(`
+SELECT id
+FROM brief_sessions
+ORDER BY updated_at DESC, id`)
+ if err != nil {
+ return nil, fmt.Errorf("list sessions: %w", err)
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan session id: %w", err)
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate session ids: %w", err)
+ }
+ sessions := make([]briefdomain.ArticleBriefSession, 0, len(ids))
+ for _, id := range ids {
+ session, ok := s.GetSession(id)
+ if !ok {
+ return nil, fmt.Errorf("session %q disappeared while listing", id)
+ }
+ sessions = append(sessions, session)
+ }
+ return sessions, nil
+}
+
// SaveBrief stores the completed brief for a session.
func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBrief) error {
if sessionID == "" {
@@ -456,6 +523,34 @@ func (s *WorkflowStore) GetBrief(sessionID string) (briefdomain.ArticleBrief, bo
return brief, true
}
+// ListBriefs returns all stored completed briefs keyed by session id.
+func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error) {
+ rows, err := s.db.Query(`
+SELECT session_id, brief_json
+FROM briefs
+ORDER BY updated_at DESC, session_id`)
+ if err != nil {
+ return nil, fmt.Errorf("list briefs: %w", err)
+ }
+ defer rows.Close()
+ briefs := map[string]briefdomain.ArticleBrief{}
+ for rows.Next() {
+ var sessionID, briefJSON string
+ var brief briefdomain.ArticleBrief
+ if err := rows.Scan(&sessionID, &briefJSON); err != nil {
+ return nil, fmt.Errorf("scan brief: %w", err)
+ }
+ if err := unmarshalString(briefJSON, &brief); err != nil {
+ return nil, fmt.Errorf("decode brief %q: %w", sessionID, err)
+ }
+ briefs[sessionID] = brief
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate briefs: %w", err)
+ }
+ return briefs, nil
+}
+
// SaveProject stores a project aggregate.
func (s *WorkflowStore) SaveProject(project ProjectRecord) error {
if strings.TrimSpace(project.ID) == "" {
@@ -499,6 +594,34 @@ func (s *WorkflowStore) GetProject(id string) (ProjectRecord, bool) {
return project, true
}
+// ListProjects returns projects in most-recently-updated order.
+func (s *WorkflowStore) ListProjects() ([]ProjectRecord, error) {
+ rows, err := s.db.Query(`
+SELECT id, name, created_at, updated_at, metadata_json
+FROM projects
+ORDER BY updated_at DESC, id`)
+ if err != nil {
+ return nil, fmt.Errorf("list projects: %w", err)
+ }
+ defer rows.Close()
+ var records []ProjectRecord
+ for rows.Next() {
+ var record ProjectRecord
+ var createdAt, updatedAt, metadataJSON string
+ if err := rows.Scan(&record.ID, &record.Name, &createdAt, &updatedAt, &metadataJSON); err != nil {
+ return nil, fmt.Errorf("scan project: %w", err)
+ }
+ record.CreatedAt = parseTime(createdAt)
+ record.UpdatedAt = parseTime(updatedAt)
+ _ = unmarshalString(metadataJSON, &record.Metadata)
+ records = append(records, record)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate projects: %w", err)
+ }
+ return records, nil
+}
+
// SaveArticle stores an article aggregate.
func (s *WorkflowStore) SaveArticle(article ArticleRecord) error {
if strings.TrimSpace(article.ID) == "" {
@@ -560,6 +683,39 @@ FROM articles WHERE id = ?`, id).Scan(&article.ID, &projectID, &article.PersonaI
return article, true
}
+// ListArticlesByProject returns articles for a project in most-recently-updated order.
+func (s *WorkflowStore) ListArticlesByProject(projectID string) ([]ArticleRecord, error) {
+ rows, err := s.db.Query(`
+SELECT id
+FROM articles
+WHERE project_id = ?
+ORDER BY updated_at DESC, id`, projectID)
+ if err != nil {
+ return nil, fmt.Errorf("list project articles: %w", err)
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan article id: %w", err)
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate article ids: %w", err)
+ }
+ records := make([]ArticleRecord, 0, len(ids))
+ for _, id := range ids {
+ record, ok := s.GetArticle(id)
+ if !ok {
+ return nil, fmt.Errorf("article %q disappeared while listing", id)
+ }
+ records = append(records, record)
+ }
+ return records, nil
+}
+
// SaveSourceSnapshot stores source selector and fetch snapshots.
func (s *WorkflowStore) SaveSourceSnapshot(snapshot SourceSnapshotRecord) error {
if strings.TrimSpace(snapshot.ID) == "" {
diff --git a/static/css/style.css b/static/css/style.css
index 2220eb5..c8086af 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -179,6 +179,48 @@ body {
color: var(--text);
}
+.history-config {
+ margin: 4px 0 14px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fbfcfe;
+}
+
+.history-config .section-heading {
+ margin-top: 0;
+}
+
+.history-picker-grid {
+ display: grid;
+ grid-template-columns: minmax(160px, 0.65fr) minmax(0, 1fr) minmax(0, 1fr);
+ gap: 14px;
+}
+
+.history-status {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--muted);
+ background: var(--surface);
+ font-size: 14px;
+}
+
+.history-status.loading {
+ border-style: dashed;
+}
+
+.history-status.empty {
+ background: #f8fafc;
+}
+
+.history-status.warning {
+ border-color: #fde68a;
+ color: var(--warning);
+ background: var(--warning-bg);
+}
+
.section-heading {
display: flex;
align-items: center;
@@ -333,6 +375,86 @@ pre {
padding: 12px;
}
+.artifact-card {
+ display: grid;
+ gap: 12px;
+ margin: 10px 0 12px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+}
+
+.artifact-card.empty {
+ color: var(--muted);
+ background: #f8fafc;
+ border-style: dashed;
+}
+
+.artifact-card-header {
+ display: grid;
+ gap: 8px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.artifact-card-header strong {
+ line-height: 1.35;
+}
+
+.artifact-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.artifact-meta span {
+ max-width: 100%;
+ padding: 3px 7px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: #f8fafc;
+ overflow-wrap: anywhere;
+}
+
+.artifact-section {
+ display: grid;
+ gap: 6px;
+}
+
+.artifact-section h4 {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.artifact-section ul {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding-left: 18px;
+}
+
+.artifact-section li {
+ overflow-wrap: anywhere;
+}
+
+.artifact-raw {
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.artifact-raw summary {
+ cursor: pointer;
+ font-weight: 650;
+}
+
+.artifact-raw pre {
+ margin-top: 8px;
+}
+
.question-log {
display: grid;
gap: 14px;
@@ -581,6 +703,7 @@ pre {
}
.config-grid,
+ .history-picker-grid,
.result-grid {
grid-template-columns: 1fr;
}
diff --git a/static/history_ui_test.go b/static/history_ui_test.go
new file mode 100644
index 0000000..76f87c5
--- /dev/null
+++ b/static/history_ui_test.go
@@ -0,0 +1,57 @@
+package static_test
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+func TestHistoryUIContract(t *testing.T) {
+ index, err := os.ReadFile("index.html")
+ if err != nil {
+ t.Fatalf("read index: %v", err)
+ }
+ script, err := os.ReadFile("js/script.js")
+ if err != nil {
+ t.Fatalf("read script: %v", err)
+ }
+ document, err := goquery.NewDocumentFromReader(bytes.NewReader(index))
+ if err != nil {
+ t.Fatalf("parse index: %v", err)
+ }
+
+ for _, selector := range []string{
+ "#history-persona-select",
+ "#history-style-select",
+ "#history-session-select",
+ "#refresh-history-btn",
+ "#open-history-btn",
+ "#clear-history-selection-btn",
+ "#history-status",
+ "#style-guide-card",
+ "#brief-card",
+ } {
+ if document.Find(selector).Length() != 1 {
+ t.Fatalf("selector %s count = %d, want 1", selector, document.Find(selector).Length())
+ }
+ }
+
+ source := string(script)
+ for _, want := range []string{
+ "const historyEndpoint = '/api/workflow/artifacts'",
+ "loadWorkflowHistory",
+ "normalizeWorkflowHistory",
+ "renderStyleGuideCard",
+ "renderBriefCard",
+ "requestJSON(`${historyEndpoint}?",
+ "/api/author-style/",
+ "/api/brief-sessions/",
+ } {
+ if !strings.Contains(source, want) {
+ t.Fatalf("script missing %q", want)
+ }
+ }
+}
diff --git a/static/index.html b/static/index.html
index d19f325..882d4e7 100644
--- a/static/index.html
+++ b/static/index.html
@@ -77,6 +77,32 @@ 保存方式
+
+
+
履歴から再開
+ 履歴を更新
+
+
+
+ 履歴の書き手
+
+
+
+ 保存済み文体ガイド
+
+
+
+ 保存済み取材セッション
+
+
+
+
+ 選択した履歴を開く
+ 選択を解除
+
+
+
+
取材質問
@@ -132,7 +158,11 @@ 文体分析 / プリセット
文体ガイド
-
+
+
+ Markdownを表示
+
+
@@ -161,7 +191,11 @@ 記事取材
記事ブリーフ
-
+
+
+ JSONを表示
+
+
diff --git a/static/js/script.js b/static/js/script.js
index bc7e6e3..427227e 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -1,5 +1,6 @@
document.addEventListener('DOMContentLoaded', () => {
const configStorageKey = 'note-maker-config-v1';
+ const historyEndpoint = '/api/workflow/artifacts';
const legacyTemplateQuestionIds = new Set([
'theme',
'opening_episode',
@@ -48,6 +49,13 @@ document.addEventListener('DOMContentLoaded', () => {
templateLoading: false,
templateError: '',
templateRequestId: 0,
+ historyStyles: [],
+ historySessions: [],
+ historyLoading: false,
+ historyError: '',
+ historyRequestId: 0,
+ selectedHistoryStyle: null,
+ selectedHistorySession: null,
storageConfig: null,
questionTextById: {},
lastSubmittedAnswer: '',
@@ -69,6 +77,13 @@ document.addEventListener('DOMContentLoaded', () => {
storagePath: document.getElementById('storage-path'),
saveStorage: document.getElementById('save-storage-btn'),
storageSummary: document.getElementById('storage-summary'),
+ historyPersonaSelect: document.getElementById('history-persona-select'),
+ historyStyleSelect: document.getElementById('history-style-select'),
+ historySessionSelect: document.getElementById('history-session-select'),
+ refreshHistory: document.getElementById('refresh-history-btn'),
+ openHistory: document.getElementById('open-history-btn'),
+ clearHistorySelection: document.getElementById('clear-history-selection-btn'),
+ historyStatus: document.getElementById('history-status'),
questionConfigList: document.getElementById('question-config-list'),
addQuestion: document.getElementById('add-question-btn'),
resetQuestions: document.getElementById('reset-questions-btn'),
@@ -80,6 +95,7 @@ document.addEventListener('DOMContentLoaded', () => {
profileId: document.getElementById('profile-id'),
guideId: document.getElementById('guide-id'),
articleCount: document.getElementById('article-count'),
+ styleGuideCard: document.getElementById('style-guide-card'),
guidePreview: document.getElementById('guide-preview'),
startInterview: document.getElementById('start-interview-btn'),
interviewArea: document.getElementById('interview-area'),
@@ -89,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => {
cancelAnswer: document.getElementById('cancel-answer-btn'),
skipDeepDive: document.getElementById('skip-deep-dive-btn'),
briefResult: document.getElementById('brief-result'),
+ briefCard: document.getElementById('brief-card'),
briefPreview: document.getElementById('brief-preview'),
generateDraft: document.getElementById('generate-draft-btn'),
cancelDraft: document.getElementById('cancel-draft-btn'),
@@ -124,6 +141,12 @@ document.addEventListener('DOMContentLoaded', () => {
el.verifyModel.addEventListener('change', saveModelConfig);
el.storageDriver.addEventListener('change', onStorageDriverChange);
el.saveStorage.addEventListener('click', saveStorageConfig);
+ el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory);
+ el.historyStyleSelect.addEventListener('change', selectHistoryStyle);
+ el.historySessionSelect.addEventListener('change', selectHistorySession);
+ el.refreshHistory.addEventListener('click', loadWorkflowHistory);
+ el.openHistory.addEventListener('click', openSelectedHistory);
+ el.clearHistorySelection.addEventListener('click', clearHistorySelection);
el.addQuestion.addEventListener('click', addQuestion);
el.resetQuestions.addEventListener('click', resetQuestions);
el.analyzeStyle.addEventListener('click', analyzeStyle);
@@ -159,9 +182,11 @@ document.addEventListener('DOMContentLoaded', () => {
state.formats = formats;
populatePersonaSelect();
populateFormatSelect();
+ populateHistoryPersonaSelect();
applyPersonaDefaults(false);
renderModeSummary();
loadQuestionTemplate();
+ loadWorkflowHistory();
} catch (error) {
showError(`書き分け設定の取得に失敗しました: ${error.message}`);
}
@@ -235,6 +260,7 @@ document.addEventListener('DOMContentLoaded', () => {
el.guideId.textContent = data.guide_id;
el.articleCount.textContent = String(data.article_count);
el.guidePreview.textContent = data.guide_markdown;
+ renderStyleGuideCard(data);
el.styleResult.classList.remove('hidden');
el.startInterview.disabled = false;
}
@@ -266,6 +292,7 @@ document.addEventListener('DOMContentLoaded', () => {
rememberQuestion(data.next_question);
el.interviewArea.classList.remove('hidden');
el.briefResult.classList.add('hidden');
+ renderBriefCard(null);
el.generateDraft.disabled = true;
renderTranscript(data);
} catch (error) {
@@ -361,6 +388,7 @@ document.addEventListener('DOMContentLoaded', () => {
state.completedBrief = data.brief;
state.nextQuestion = null;
el.briefPreview.textContent = JSON.stringify(data.brief, null, 2);
+ renderBriefCard(data.brief);
el.briefResult.classList.remove('hidden');
el.generateDraft.disabled = false;
el.skipDeepDive.classList.add('hidden');
@@ -369,6 +397,7 @@ document.addEventListener('DOMContentLoaded', () => {
state.completedBrief = null;
state.nextQuestion = data.next_question;
el.briefResult.classList.add('hidden');
+ renderBriefCard(null);
el.generateDraft.disabled = true;
el.skipDeepDive.classList.toggle('hidden', data.next_question?.flow_type !== 'deep_dive_follow_up');
}
@@ -740,6 +769,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
+ function populateHistoryPersonaSelect() {
+ el.historyPersonaSelect.innerHTML = '';
+ state.personas.forEach((persona) => {
+ const option = document.createElement('option');
+ option.value = persona.id;
+ option.textContent = persona.display_name;
+ option.selected = persona.id === currentPersonaId();
+ el.historyPersonaSelect.appendChild(option);
+ });
+ if (!el.historyPersonaSelect.value && state.personas[0]) {
+ el.historyPersonaSelect.value = state.personas[0].id;
+ }
+ }
+
function onPersonaChange() {
config.mode.persona = currentPersonaId();
applyPersonaDefaults(true);
@@ -747,6 +790,8 @@ document.addEventListener('DOMContentLoaded', () => {
saveConfig();
renderModeSummary();
loadQuestionTemplate();
+ syncHistoryPersonaToCurrentMode();
+ loadWorkflowHistory();
}
function onFormatChange() {
@@ -755,8 +800,390 @@ document.addEventListener('DOMContentLoaded', () => {
saveConfig();
renderModeSummary();
loadQuestionTemplate();
+ loadWorkflowHistory();
+ }
+
+ function syncHistoryPersonaToCurrentMode() {
+ if (el.historyPersonaSelect.value !== currentPersonaId()) {
+ el.historyPersonaSelect.value = currentPersonaId();
+ }
+ }
+
+ async function loadWorkflowHistory() {
+ const personaId = el.historyPersonaSelect.value || currentPersonaId();
+ const formatId = currentFormatId();
+ const requestId = state.historyRequestId + 1;
+ state.historyRequestId = requestId;
+ state.historyLoading = true;
+ state.historyError = '';
+ state.selectedHistoryStyle = null;
+ state.selectedHistorySession = null;
+ renderHistoryPicker();
+
+ try {
+ const data = await fetchWorkflowHistoryIndex({ personaId, formatId });
+ if (requestId !== state.historyRequestId) {
+ return;
+ }
+ const normalized = normalizeWorkflowHistory(data, personaId, formatId);
+ state.historyStyles = normalized.styles;
+ state.historySessions = normalized.sessions;
+ } catch (error) {
+ if (requestId !== state.historyRequestId) {
+ return;
+ }
+ state.historyStyles = [];
+ state.historySessions = [];
+ state.historyError = historyErrorMessage(error);
+ } finally {
+ if (requestId === state.historyRequestId) {
+ state.historyLoading = false;
+ renderHistoryPicker();
+ }
+ }
+ }
+
+ async function fetchWorkflowHistoryIndex({ personaId, formatId }) {
+ const params = new URLSearchParams();
+ if (personaId) {
+ params.set('persona_id', personaId);
+ }
+ if (formatId) {
+ params.set('format_id', formatId);
+ }
+ return requestJSON(`${historyEndpoint}?${params.toString()}`);
+ }
+
+ function renderHistoryPicker() {
+ renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択');
+ renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択');
+
+ el.historyStyleSelect.disabled = state.historyLoading || !state.historyStyles.length;
+ el.historySessionSelect.disabled = state.historyLoading || !state.historySessions.length;
+ el.openHistory.disabled = state.historyLoading || !historySelectionReady();
+
+ if (state.historyLoading) {
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '保存済みの文体ガイドと取材セッションを読み込んでいます...';
+ return;
+ }
+ if (state.historyError) {
+ el.historyStatus.className = 'history-status warning';
+ el.historyStatus.textContent = state.historyError;
+ return;
+ }
+ if (!state.historyStyles.length && !state.historySessions.length) {
+ el.historyStatus.className = 'history-status empty';
+ el.historyStatus.textContent = 'この書き手と出力先の保存済み履歴はまだありません。';
+ return;
+ }
+ const styleCount = `${state.historyStyles.length}件の文体ガイド`;
+ const sessionCount = `${state.historySessions.length}件の取材セッション`;
+ el.historyStatus.className = 'history-status';
+ el.historyStatus.textContent = `${styleCount} / ${sessionCount} を選択できます。`;
+ }
+
+ function renderHistoryOptions(select, items, placeholder) {
+ const selected = select.value;
+ select.innerHTML = '';
+ const empty = document.createElement('option');
+ empty.value = '';
+ empty.textContent = placeholder;
+ select.appendChild(empty);
+ items.forEach((item) => {
+ const option = document.createElement('option');
+ option.value = item.id;
+ option.textContent = historyOptionLabel(item);
+ select.appendChild(option);
+ });
+ if (items.some((item) => item.id === selected)) {
+ select.value = selected;
+ }
+ }
+
+ function historyOptionLabel(item) {
+ const title = item.title || item.theme || item.label || item.id;
+ const status = item.completed === true ? '完了' : item.phase || '';
+ const updatedAt = formatDateTime(item.updatedAt || item.createdAt);
+ return [title, status, updatedAt].filter(Boolean).join(' / ');
+ }
+
+ function historySelectionReady() {
+ return Boolean(el.historyStyleSelect.value || el.historySessionSelect.value);
+ }
+
+ function historyErrorMessage(error) {
+ const message = error.message || '';
+ if (message.includes('HTTP 404')) {
+ return '履歴APIはまだ接続されていません。バックエンド実装後にここへ保存済み履歴が表示されます。';
+ }
+ return `履歴の取得に失敗しました: ${message}`;
+ }
+
+ async function selectHistoryStyle() {
+ state.selectedHistoryStyle = findHistoryStyle(el.historyStyleSelect.value);
+ el.openHistory.disabled = !historySelectionReady();
+ if (!state.selectedHistoryStyle) {
+ return;
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '保存済み文体ガイドを確認しています...';
+ try {
+ const detail = await loadHistoryStyleDetail(state.selectedHistoryStyle);
+ state.selectedHistoryStyle = detail;
+ renderStyleGuideCard(detail);
+ el.guidePreview.textContent = styleGuideMarkdown(detail);
+ el.styleResult.classList.remove('hidden');
+ renderHistoryPicker();
+ } catch (error) {
+ el.historyStatus.className = 'history-status warning';
+ el.historyStatus.textContent = `文体ガイドを開けませんでした: ${error.message}`;
+ }
+ }
+
+ async function selectHistorySession() {
+ state.selectedHistorySession = findHistorySession(el.historySessionSelect.value);
+ el.openHistory.disabled = !historySelectionReady();
+ if (!state.selectedHistorySession) {
+ return;
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '保存済み取材セッションを確認しています...';
+ try {
+ const detail = await loadHistorySessionDetail(state.selectedHistorySession);
+ state.selectedHistorySession = detail;
+ if (detail.brief) {
+ renderBriefCard(detail.brief);
+ el.briefPreview.textContent = JSON.stringify(detail.brief, null, 2);
+ el.briefResult.classList.remove('hidden');
+ }
+ renderHistoryPicker();
+ } catch (error) {
+ el.historyStatus.className = 'history-status warning';
+ el.historyStatus.textContent = `取材セッションを開けませんでした: ${error.message}`;
+ }
+ }
+
+ async function openSelectedHistory() {
+ clearError();
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '選択した履歴を開いています...';
+ try {
+ const style = el.historyStyleSelect.value
+ ? await loadHistoryStyleDetail(state.selectedHistoryStyle || findHistoryStyle(el.historyStyleSelect.value))
+ : null;
+ const session = el.historySessionSelect.value
+ ? await loadHistorySessionDetail(state.selectedHistorySession || findHistorySession(el.historySessionSelect.value))
+ : null;
+ const styleForSession = !style && session?.styleProfileId
+ ? await loadHistoryStyleDetail({ id: session.styleProfileId })
+ : style;
+
+ if (styleForSession) {
+ applyHistoryStyle(styleForSession);
+ }
+ if (session) {
+ await applyHistorySession(session);
+ }
+ if (!styleForSession && !session) {
+ showError('開く履歴を選択してください');
+ return;
+ }
+ el.historyStatus.className = 'history-status';
+ el.historyStatus.textContent = '選択した履歴を現在の作業状態に反映しました。';
+ } catch (error) {
+ el.historyStatus.className = 'history-status warning';
+ el.historyStatus.textContent = `履歴を開けませんでした: ${error.message}`;
+ }
+ }
+
+ function clearHistorySelection() {
+ el.historyStyleSelect.value = '';
+ el.historySessionSelect.value = '';
+ state.selectedHistoryStyle = null;
+ state.selectedHistorySession = null;
+ renderHistoryPicker();
+ }
+
+ async function loadHistoryStyleDetail(item) {
+ if (!item) {
+ return null;
+ }
+ if (styleGuideMarkdown(item)) {
+ return item;
+ }
+ const data = await requestJSON(`/api/author-style/${encodeURIComponent(item.id)}`);
+ return normalizeHistoryStyle({ ...item, ...data });
+ }
+
+ async function loadHistorySessionDetail(item) {
+ if (!item) {
+ return null;
+ }
+ if (item.answers?.length || item.brief || item.nextQuestion) {
+ return item;
+ }
+ const data = await requestJSON(`/api/brief-sessions/${encodeURIComponent(item.id)}`);
+ return normalizeHistorySession({ ...item, ...data });
}
+ function applyHistoryStyle(item) {
+ const data = normalizeHistoryStyle(item);
+ state.profileId = data.profileId || data.id;
+ el.profileId.textContent = state.profileId;
+ el.guideId.textContent = data.guideId || '';
+ el.articleCount.textContent = data.articleCount === undefined ? '' : String(data.articleCount);
+ el.guidePreview.textContent = styleGuideMarkdown(data);
+ renderStyleGuideCard(data);
+ el.styleResult.classList.remove('hidden');
+ el.startInterview.disabled = !state.profileId;
+ }
+
+ async function applyHistorySession(item) {
+ const data = normalizeHistorySession(item);
+ if (data.personaId && state.personas.some((persona) => persona.id === data.personaId)) {
+ el.personaSelect.value = data.personaId;
+ config.mode.persona = data.personaId;
+ }
+ if (data.outputFormatId && state.formats.some((format) => format.id === data.outputFormatId)) {
+ el.formatSelect.value = data.outputFormatId;
+ config.mode.format = data.outputFormatId;
+ }
+ saveConfig();
+ renderModeSummary();
+ applyStyleSourceDefault(true);
+ await loadQuestionTemplate();
+ state.sessionId = data.id;
+ state.parentSessionId = data.parentSessionId || '';
+ state.profileId = data.styleProfileId || state.profileId;
+ state.answers = data.answers || [];
+ state.nextQuestion = data.nextQuestion || null;
+ state.completedBrief = data.completed ? data.brief : null;
+ rememberQuestions(data.questions || state.templateQuestions);
+ rememberQuestion(data.nextQuestion);
+ el.interviewArea.classList.remove('hidden');
+ renderTranscript({
+ answers: state.answers,
+ next_question: state.nextQuestion,
+ completed: data.completed,
+ });
+ if (data.completed && data.brief) {
+ el.briefPreview.textContent = JSON.stringify(data.brief, null, 2);
+ renderBriefCard(data.brief);
+ el.briefResult.classList.remove('hidden');
+ el.generateDraft.disabled = !state.profileId;
+ el.skipDeepDive.classList.add('hidden');
+ } else {
+ renderBriefCard(null);
+ el.briefResult.classList.add('hidden');
+ el.generateDraft.disabled = true;
+ el.skipDeepDive.classList.toggle('hidden', data.nextQuestion?.flow_type !== 'deep_dive_follow_up');
+ }
+ updateSectionControls();
+ }
+
+ function normalizeWorkflowHistory(data, personaId, formatId) {
+ const source = data || {};
+ const styleValues = arrayFrom(source.style_guides || source.styleGuides || source.styles || source.author_styles || source.authorStyles || source.profiles);
+ const sessionValues = [
+ ...arrayFrom(source.sessions || source.brief_sessions || source.briefSessions || source.items || (Array.isArray(source) ? source : [])),
+ ...arrayFrom(source.briefs || source.Briefs),
+ ];
+ return {
+ styles: styleValues.map(normalizeHistoryStyle)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId)),
+ sessions: uniqueHistoryItems(sessionValues.map(normalizeHistorySession)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
+ };
+ }
+
+ function normalizeHistoryStyle(item = {}) {
+ const profile = item.profile || item.Profile || {};
+ const guide = item.guide || item.Guide || {};
+ return {
+ ...item,
+ id: String(item.profile_id || item.profileId || item.style_profile_id || item.styleProfileId || profile.id || profile.ID || item.id || item.ID || '').trim(),
+ resultId: item.id || item.ID || '',
+ profileId: item.profile_id || item.profileId || item.style_profile_id || item.styleProfileId || profile.id || profile.ID || '',
+ guideId: item.guide_id || item.guideId || guide.id || guide.ID || '',
+ title: item.title || item.name || item.label || item.display_name || item.displayName || profile.name || profile.Name || '',
+ personaId: item.persona_id || item.personaId || profile.persona_id || profile.PersonaID || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || profile.output_format_id || profile.OutputFormatID || '',
+ articleCount: item.article_count ?? item.articleCount ?? item.source?.article_count ?? item.Source?.ArticleCount,
+ guideMarkdown: item.guide_markdown || item.guideMarkdown || item.markdown || item.Markdown || guide.markdown || guide.Markdown || '',
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || '',
+ createdAt: item.created_at || item.createdAt || '',
+ source: item.source || item.Source || {},
+ profile,
+ guide,
+ };
+ }
+
+ function normalizeHistorySession(item = {}) {
+ const brief = item.brief || item.Brief || null;
+ const title = item.title || item.name || briefField(brief, 'theme', 'Theme') || '';
+ return {
+ ...item,
+ id: String(item.session_id || item.sessionId || item.id || item.ID || '').trim(),
+ title,
+ theme: briefField(brief, 'theme', 'Theme'),
+ styleProfileId: item.style_profile_id || item.styleProfileId || item.profile_id || item.profileId || briefField(brief, 'styleProfileId', 'StyleProfileID') || '',
+ personaId: item.persona_id || item.personaId || briefField(brief, 'personaId', 'PersonaID') || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || briefField(brief, 'outputFormatId', 'OutputFormatID') || '',
+ parentSessionId: item.parent_session_id || item.parentSessionId || '',
+ phase: item.phase || item.Phase || '',
+ completed: item.completed ?? item.Completed ?? Boolean(brief),
+ brief,
+ answers: item.answers || item.Answers || [],
+ questions: normalizeQuestionList(item.questions || item.Questions || []),
+ nextQuestion: normalizeHistoryQuestion(item.next_question || item.nextQuestion || item.NextQuestion || null),
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || '',
+ createdAt: item.created_at || item.createdAt || '',
+ };
+ }
+
+ function normalizeHistoryQuestion(question) {
+ if (!question) {
+ return null;
+ }
+ return {
+ id: question.id || question.ID || '',
+ text: question.text || question.Text || '',
+ flow_type: question.flow_type || question.flowType || question.FlowType || 'main',
+ target_field: question.target_field || question.targetField || question.TargetField || '',
+ target_question_id: question.target_question_id || question.targetQuestionId || question.TargetQuestionID || '',
+ follow_up_index: question.follow_up_index || question.followUpIndex || question.FollowUpIndex || 0,
+ required: question.required ?? question.Required,
+ };
+ }
+
+ function historyItemMatches(item, personaId, formatId) {
+ return (!item.personaId || !personaId || item.personaId === personaId)
+ && (!item.outputFormatId || !formatId || item.outputFormatId === formatId);
+ }
+
+ function findHistoryStyle(id) {
+ return state.historyStyles.find((item) => item.id === id) || null;
+ }
+
+ function findHistorySession(id) {
+ return state.historySessions.find((item) => item.id === id) || null;
+ }
+
+ function uniqueHistoryItems(items) {
+ const byId = new Map();
+ items.forEach((item) => {
+ const existing = byId.get(item.id);
+ if (!existing || (!existing.brief && item.brief)) {
+ byId.set(item.id, item);
+ }
+ });
+ return [...byId.values()];
+ }
+
+
function applyPersonaDefaults(forceFormat) {
const persona = currentPersona();
if (!persona) {
@@ -1148,6 +1575,180 @@ document.addEventListener('DOMContentLoaded', () => {
.replaceAll("'", ''');
}
+ function renderStyleGuideCard(data) {
+ el.styleGuideCard.innerHTML = '';
+ const markdown = styleGuideMarkdown(data);
+ if (!data || !markdown) {
+ el.styleGuideCard.className = 'artifact-card empty';
+ el.styleGuideCard.textContent = '文体ガイドはまだありません。文体分析または保存済み履歴から選択してください。';
+ return;
+ }
+ const normalized = normalizeHistoryStyle(data);
+ el.styleGuideCard.className = 'artifact-card';
+ el.styleGuideCard.appendChild(createArtifactHeader(
+ normalized.title || '文体ガイド',
+ [
+ ['Profile', normalized.profileId || normalized.id],
+ ['Guide', normalized.guideId],
+ ['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)],
+ ],
+ ));
+ const sections = markdownSectionsForCard(markdown);
+ if (sections.length) {
+ sections.slice(0, 5).forEach((section) => {
+ el.styleGuideCard.appendChild(createArtifactSection(section.title, section.items));
+ });
+ } else {
+ el.styleGuideCard.appendChild(createArtifactSection('要点', compactTextLines(markdown, 6)));
+ }
+ }
+
+ function renderBriefCard(brief) {
+ el.briefCard.innerHTML = '';
+ if (!brief) {
+ el.briefCard.className = 'artifact-card empty';
+ el.briefCard.textContent = '記事ブリーフはまだありません。取材完了後、または保存済みセッション選択後に表示します。';
+ return;
+ }
+ el.briefCard.className = 'artifact-card';
+ const theme = briefField(brief, 'theme', 'Theme') || '記事ブリーフ';
+ el.briefCard.appendChild(createArtifactHeader(
+ theme,
+ [
+ ['Persona', briefField(brief, 'persona_id', 'PersonaID')],
+ ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')],
+ ['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')],
+ ],
+ ));
+ [
+ ['読者', briefField(brief, 'reader', 'Reader')],
+ ['冒頭の具体例', briefField(brief, 'opening_episode', 'OpeningEpisode')],
+ ['読後アクション', briefField(brief, 'expected_reader_action', 'ExpectedReaderAction')],
+ ['必ず含めること', briefField(brief, 'must_include', 'MustInclude')],
+ ['本人文脈', briefField(brief, 'personal_context', 'PersonalContext')],
+ ['含めないこと', briefField(brief, 'exclusions', 'Exclusions')],
+ ['構成と長さ', briefField(brief, 'target_length_structure', 'TargetLengthStructure')],
+ ['トーンと立場', briefField(brief, 'tone_stance', 'ToneStance')],
+ ].filter(([, value]) => String(value || '').trim()).forEach(([label, value]) => {
+ el.briefCard.appendChild(createArtifactSection(label, [value]));
+ });
+ const customAnswers = brief.CustomAnswers || brief.custom_answers || brief.customAnswers || [];
+ const deepDives = brief.DeepDives || brief.deep_dives || brief.deepDives || [];
+ if (customAnswers.length) {
+ el.briefCard.appendChild(createAnswerSection('追加回答', customAnswers));
+ }
+ if (deepDives.length) {
+ el.briefCard.appendChild(createAnswerSection('深掘りメモ', deepDives));
+ }
+ }
+
+ function createArtifactHeader(title, metaItems) {
+ const header = document.createElement('div');
+ header.className = 'artifact-card-header';
+ const titleElement = document.createElement('strong');
+ titleElement.textContent = title;
+ const meta = document.createElement('div');
+ meta.className = 'artifact-meta';
+ metaItems.filter(([, value]) => value !== undefined && value !== null && String(value).trim()).forEach(([label, value]) => {
+ const item = document.createElement('span');
+ item.textContent = `${label}: ${value}`;
+ meta.appendChild(item);
+ });
+ header.append(titleElement, meta);
+ return header;
+ }
+
+ function createArtifactSection(title, values) {
+ const section = document.createElement('section');
+ section.className = 'artifact-section';
+ const heading = document.createElement('h4');
+ heading.textContent = title;
+ section.appendChild(heading);
+ const list = document.createElement('ul');
+ values.filter((value) => String(value || '').trim()).forEach((value) => {
+ const item = document.createElement('li');
+ item.textContent = String(value).trim();
+ list.appendChild(item);
+ });
+ section.appendChild(list);
+ return section;
+ }
+
+ function createAnswerSection(title, answers) {
+ return createArtifactSection(title, answers.map((answer) => {
+ const questionId = answerValue(answer, 'question_id', 'QuestionID');
+ const content = answerValue(answer, 'content', 'Content');
+ const question = state.questionTextById[questionId] || questionId || '回答';
+ return `${question}: ${content}`;
+ }));
+ }
+
+ function markdownSectionsForCard(markdown) {
+ const sections = [];
+ let current = { title: '要点', items: [] };
+ String(markdown || '').split('\n').forEach((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return;
+ }
+ const heading = trimmed.match(/^#{1,4}\s+(.+)$/);
+ if (heading) {
+ if (current.items.length) {
+ sections.push(current);
+ }
+ current = { title: heading[1].trim(), items: [] };
+ return;
+ }
+ current.items.push(trimmed.replace(/^[-*]\s+/, ''));
+ });
+ if (current.items.length) {
+ sections.push(current);
+ }
+ return sections.map((section) => ({
+ title: section.title,
+ items: section.items.slice(0, 6),
+ }));
+ }
+
+ function compactTextLines(value, limit) {
+ return String(value || '').split('\n').map((line) => line.trim()).filter(Boolean).slice(0, limit);
+ }
+
+ function styleGuideMarkdown(data = {}) {
+ return data.guideMarkdown || data.guide_markdown || data.markdown || data.Markdown || data.guide?.markdown || data.guide?.Markdown || data.Guide?.Markdown || '';
+ }
+
+ function briefField(brief, snake, pascal) {
+ if (!brief) {
+ return '';
+ }
+ return brief[snake] ?? brief[toCamelCase(snake)] ?? brief[pascal] ?? '';
+ }
+
+ function toCamelCase(value) {
+ return String(value || '').replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
+ }
+
+ function arrayFrom(value) {
+ return Array.isArray(value) ? value : [];
+ }
+
+ function formatDateTime(value) {
+ if (!value) {
+ return '';
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return '';
+ }
+ return date.toLocaleString('ja-JP', {
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ }
+
function renderDraft(data) {
const qualityGate = data.quality_gate || data.qualityGate || {};
const evaluation = data.evaluation;
From 59d24e1ff140e7eaea3a6553cfe72d173677c12b Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 17:57:00 +0900
Subject: [PATCH 29/33] Implement project history and browser contracts
Closes #83
---
cmd/server/main.go | 4 +
cmd/server/main_test.go | 87 +++
...02-multi-persona-multi-format-extension.md | 8 +-
.../next-implementation-cut.md | 41 +-
...13-browser-contract-coverage-2026-05-03.md | 59 ++
...ssue-27-28-history-artifacts-2026-05-03.md | 27 +-
internal/handlers/workflow.go | 589 ++++++++++++++-
.../handlers/workflow_history_api_test.go | 452 ++++++++++++
static/css/style.css | 15 +-
static/history_ui_test.go | 414 ++++++++++-
static/index.html | 20 +
static/js/script.js | 682 +++++++++++++++++-
12 files changed, 2358 insertions(+), 40 deletions(-)
create mode 100644 docs/validation/issue-13-browser-contract-coverage-2026-05-03.md
create mode 100644 internal/handlers/workflow_history_api_test.go
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 0c6b261..eb1f65a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -61,7 +61,11 @@ func registerRoutes(r *mux.Router) {
r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST")
r.HandleFunc("/api/brief-sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST")
r.HandleFunc("/api/sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST")
+ r.HandleFunc("/api/projects", handlers.ListProjectsHandler).Methods("GET")
+ r.HandleFunc("/api/projects/{id}", handlers.GetProjectHandler).Methods("GET")
+ r.HandleFunc("/api/articles/{id}", handlers.GetArticleHandler).Methods("GET")
r.HandleFunc("/api/drafts", handlers.GenerateDraftHandler).Methods("POST")
+ r.HandleFunc("/api/drafts/{id}", handlers.GetDraftHandler).Methods("GET")
r.HandleFunc("/api/drafts/{id}/regenerate-section", handlers.RegenerateDraftSectionHandler).Methods("POST")
// ルートパスへのアクセスはindex.htmlにリダイレクト
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
index 24889ed..6df47bb 100644
--- a/cmd/server/main_test.go
+++ b/cmd/server/main_test.go
@@ -1,7 +1,11 @@
package main
import (
+ "io"
"net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
"testing"
"github.com/gorilla/mux"
@@ -18,9 +22,15 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
{method: http.MethodGet, path: "/api/history"},
{method: http.MethodGet, path: "/api/workflow/artifacts"},
{method: http.MethodGet, path: "/api/author-style"},
+ {method: http.MethodGet, path: "/api/author-style/style-1"},
{method: http.MethodGet, path: "/api/brief-sessions"},
+ {method: http.MethodGet, path: "/api/brief-sessions/templates"},
+ {method: http.MethodGet, path: "/api/brief-sessions/session-1"},
{method: http.MethodGet, path: "/api/briefs"},
{method: http.MethodGet, path: "/api/briefs/session-1"},
+ {method: http.MethodGet, path: "/api/models"},
+ {method: http.MethodGet, path: "/api/personas"},
+ {method: http.MethodGet, path: "/api/formats"},
} {
request, err := http.NewRequest(tt.method, tt.path, nil)
if err != nil {
@@ -32,3 +42,80 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
}
}
}
+
+func TestRegisterRoutesServesBrowserEntryAndStaticScript(t *testing.T) {
+ chdir(t, "../..")
+
+ router := mux.NewRouter()
+ registerRoutes(router)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ for _, tt := range []struct {
+ path string
+ contentType string
+ body []string
+ }{
+ {
+ path: "/",
+ contentType: "text/html",
+ body: []string{
+ ``,
+ },
+ },
+ {
+ path: "/static/js/script.js",
+ contentType: "javascript",
+ body: []string{
+ "const configStorageKey = 'note-maker-config-v1'",
+ "const historyEndpoint = '/api/workflow/artifacts'",
+ "function saveModelConfig()",
+ "function openSelectedHistory()",
+ "function renderBriefCard(brief)",
+ },
+ },
+ } {
+ t.Run(tt.path, func(t *testing.T) {
+ response, err := http.Get(server.URL + tt.path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", tt.path, err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("GET %s status = %d, want %d", tt.path, response.StatusCode, http.StatusOK)
+ }
+ if got := response.Header.Get("Content-Type"); got == "" || !strings.Contains(got, tt.contentType) {
+ t.Fatalf("GET %s Content-Type = %q, want contains %q", tt.path, got, tt.contentType)
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatalf("read response body: %v", err)
+ }
+ for _, want := range tt.body {
+ if !strings.Contains(string(body), want) {
+ t.Fatalf("GET %s body missing %q", tt.path, want)
+ }
+ }
+ })
+ }
+}
+
+func chdir(t *testing.T, dir string) {
+ t.Helper()
+ previous, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("get cwd: %v", err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir %s: %v", dir, err)
+ }
+ t.Cleanup(func() {
+ if err := os.Chdir(previous); err != nil {
+ t.Fatalf("restore cwd: %v", err)
+ }
+ })
+}
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index beef122..83edf59 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -222,6 +222,8 @@ Current implementation status as of 2026-05-03:
- Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)).
- Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)).
- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
+- The #13 follow-up has browser-adjacent contract coverage for the static HTML, script selectors, persistence hooks, workflow read routes, and artifact-card structure. It does not close #13: Playwright or equivalent real browser coverage is still required for persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and localStorage migration. Validation is recorded in [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
+- A Phase C project/article/draft history follow-up now builds on the #26 SQLite schema: `GET /api/workflow/artifacts` includes project/article/draft summaries when SQLite is active, focused read routes expose project/article/draft details, and the history UI renders project, article, brief, current draft, draft versions, and source snapshot cards. The handler/server/static tests are green, but until the browser flow is covered by #13, the canonical completed Phase C closure claim remains separate from full browser E2E.
- Phase D1 is implemented and merged: handler tests now cover template selection, edit/fork errors, SSE follow-up and draft paths, completed-session draft fallback, regenerate-section context recovery, Analyze/Generate compatibility handlers, and SQLite driver selection. `go test ./internal/handlers -cover` reports 80%+ statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)).
- Runtime runner support is implemented and merged: `cmd/scenario/live_media_matrix` reads the offline matrix, emits planned aggregate JSON/Markdown by default, and executes live Evo X2 draft runs only when `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` is used ([#57](https://github.com/terisuke/note_maker/issues/57)).
- The 2026-05-03 browser 500 analysis showed an implementation drift: plain web-app startup still defaulted to workstation-local `127.0.0.1:8081`, while this ADR requires Evo X2 Tailnet as primary. Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the default order to Evo X2 Ollama over Tailnet → Evo X2 llama.cpp → workstation-local llama.cpp and makes the UI show the actual endpoint/model reported by SSE.
@@ -235,8 +237,8 @@ Current implementation status as of 2026-05-03:
Near-term execution order:
1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check.
-2. Land the #27/#28 history/artifact read cut and add Browser E2E coverage ([#13](https://github.com/terisuke/note_maker/issues/13)) for history opening, readable cards, and the existing edit/fork/stream/regenerate flows.
-3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, and richer project/article/draft history surfaces from the #26 SQLite schema.
+2. Keep [#13](https://github.com/terisuke/note_maker/issues/13) open after the browser-contract pass and add real browser E2E coverage for history opening, readable cards, persona/format switching, question config, edit/fork, streaming/cancel, regenerate-section, and localStorage migration.
+3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 first cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, richer project/article/draft history polish, and browser E2E over the project history cards.
4. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate.
## Tracked issues
@@ -254,7 +256,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- B5 — [#25](https://github.com/terisuke/note_maker/issues/25) Format- and persona-aware fixed question sets
- C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store.
- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — implemented in the current cut for saved style-guide and brief-session reuse through `履歴から再開`, backed by `GET /api/workflow/artifacts`, `GET /api/author-style`, and `GET /api/brief-sessions`. Add-persona authoring UI and broader edit-persistence expectations remain follow-up work.
-- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Rich project/article/draft artifact browsing remains a later Phase C layer on top of the #26 SQLite schema.
+- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Project/article/draft artifact browsing now has read APIs and history cards, but real browser E2E and remaining edit/add-persona semantics are still outside the #27/#28 first-cut closure claim.
- D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` — implemented in the current cut with 80.0% handler package coverage.
- Runtime runner — [#57](https://github.com/terisuke/note_maker/issues/57) Add live LLM media-matrix runner and aggregate evaluator, feeding [#40](https://github.com/terisuke/note_maker/issues/40) — implemented in the current cut.
- Runtime stabilization epic — [#40](https://github.com/terisuke/note_maker/issues/40) Stabilize Tailnet Evo X2 draft quality and runtime metrics. #70-#73 provide the prerequisite validation and diagnostics. [#74](https://github.com/terisuke/note_maker/issues/74) has passed the bounded Cloudia/Zenn and Cloudia/Qiita proofs plus the final `5/5` publishing-target matrix.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 6502df0..7619390 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -34,10 +34,16 @@ Implemented in the current #27/#28 history/artifact cut:
- Focused tests — `internal/handlers/workflow_history_test.go` covers saved artifact responses and brief detail errors; `static/history_ui_test.go` locks the frontend contract.
- Validation — [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
+Implemented in the #13 follow-up contract cut:
+
+- Static browser contract coverage checks the HTML entrypoint, production script, model selectors, question controls, history controls, artifact-card containers, persistence hooks, and route-table wiring.
+- This is useful pre-Playwright coverage, but it is not equivalent to browser E2E. It does not exercise real DOM events, fetch stubbing, localStorage reload behavior, SSE/cancel, or the section-regeneration workflow in a browser.
+- Validation — [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
+
Open and active:
- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
-- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
+- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13), still open after the contract-test cut.
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40), now satisfied for the current note/Qiita/Zenn/Cor blog publishing-target acceptance scope by the 2026-05-03 full Tailnet Evo X2 matrix.
- Runtime evaluation sub-issue [#74](https://github.com/terisuke/note_maker/issues/74), satisfied by the staged reruns and the final `5/5` full matrix pass.
- Fallback and packaging follow-up: [#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15).
@@ -50,7 +56,32 @@ Remaining Phase C gaps after the current #27/#28 cut:
- Add-persona authoring UI is not implemented; the current UI consumes seeded personas and saved artifacts.
- Broader edit persistence called out in the issue text is not implemented beyond the existing fork-on-edit/session/brief save paths.
-- Project/article/draft artifact browsing from SQLite's normalized #26 schema is not exposed in the web UI yet; this cut intentionally uses style guides, sessions, and completed briefs as the reusable history surface.
+- Project/article/draft artifact browsing from SQLite's normalized #26 schema now has server routes, response-shape contract coverage, frontend selectors, and readable history cards. The remaining gap is real browser E2E over those cards and broader edit/add-persona semantics, so #13/#14/#27/#28 should stay open unless the owner explicitly accepts a narrower first-cut closure.
+
+## Current Review Findings
+
+Review date: 2026-05-03 on `codex/phase-c-history-e2e`.
+
+Validation that passed:
+
+```sh
+go test ./...
+go test ./internal/handlers ./static
+go test ./cmd/server ./internal/handlers ./static
+node --check static/js/script.js
+git diff --check
+```
+
+No blocking code-risk finding remains in the targeted suite after the parallel fixes. The remaining closure risk is coverage scope: #13 still lacks real browser E2E, and #27/#28 still contain product scope that is broader than the first cut.
+
+## Issue Close/Open Proposal
+
+| Issue | Proposal | Rationale |
+|---|---|---|
+| [#13](https://github.com/terisuke/note_maker/issues/13) | Keep open | Static and route contract coverage was added, but the issue asks for browser E2E. Real browser coverage still needs model config, question customisation, persona/format switching, history open, readable cards, streaming/cancel, edit/fork, regenerate-section, and localStorage migration. |
+| [#14](https://github.com/terisuke/note_maker/issues/14) | Keep open | #26 gives the SQLite schema and restart-capable storage foundation, but the product still lacks full queryable project/article/draft browsing and versioned edit/history surfaces. |
+| [#27](https://github.com/terisuke/note_maker/issues/27) | Keep open, or close only if the issue owner accepts the first-cut scope | Saved style-guide/session reuse is implemented, but the original issue still includes add-persona authoring UI, project/article navigation, restart semantics, and broader edit persistence. |
+| [#28](https://github.com/terisuke/note_maker/issues/28) | Keep open, or close only if the issue owner accepts the first-cut scope | Readable style-guide and brief cards landed, but editable card persistence/versioning and richer project/article/draft artifacts are still outstanding. |
## Final evaluation target
@@ -124,14 +155,14 @@ Use subagents with disjoint write scopes when implementation resumes:
|---|---|---|---|---|
| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | done for this cut | style-guide/session history picker and readable brief/style cards use persisted workflow state |
-| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, history open, readable cards, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
+| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | contract tests are done; close only after persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and legacy localStorage migration are covered in a browser |
| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing are split from the #27/#28 first cut |
Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Land the current #27/#28 history/artifact cut with its validation doc, then wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming, and regenerate-section coverage goals.
-2. Split the remaining Phase C work into explicit follow-up issues before implementation: add-persona authoring UI, broader edit persistence semantics, and project/article/draft artifact browsing from the #26 SQLite schema.
+1. Wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming/cancel, and regenerate-section coverage goals.
+2. Split the remaining Phase C product work into explicit follow-up issues before broadening implementation: add-persona authoring UI, broader edit persistence semantics, project/article/draft artifact browsing polish from the #26 SQLite schema, and real browser E2E over these history paths.
3. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts.
4. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate.
diff --git a/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md
new file mode 100644
index 0000000..2738589
--- /dev/null
+++ b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md
@@ -0,0 +1,59 @@
+# Issue #13 Browser Contract Coverage
+
+Date: 2026-05-03
+Branch: `codex/phase-c-history-e2e`
+
+## Scope
+
+Added practical browser-adjacent coverage that remains part of `go test ./...`:
+
+- Static HTML contract checks for model selectors, question controls, history controls, and artifact card containers.
+- Static JavaScript contract checks for model config persistence, custom question add/edit/delete/reset behavior, history loading/opening flow, and readable artifact-card rendering.
+- `httptest` coverage that the server route table exposes workflow read APIs and serves the browser entrypoint plus the production script.
+
+This validates contract shape, not user behavior in a real browser. Issue [#13](https://github.com/terisuke/note_maker/issues/13) stays open until Playwright or equivalent browser E2E covers the actual flows.
+
+## Why Not Playwright Yet
+
+The current frontend script is a single `DOMContentLoaded` closure with no importable UI functions. A Playwright suite is still the right next step, but adding it now would require a heavier test harness with stubbed API routes and browser setup that is not yet present in this repository.
+
+This pass keeps CI friction low by expanding Go tests first. The contract tests intentionally lock DOM IDs, event bindings, persistence calls, fetch endpoints, and card-rendering structure so a later Playwright suite has stable selectors and scenarios to target.
+
+## Next Playwright Path
+
+Recommended scenarios when browser E2E is introduced:
+
+1. Stub `/api/models`, `/api/personas`, `/api/formats`, `/api/brief-sessions/templates`, and `/api/workflow/artifacts`.
+2. Assert all four model selectors populate from `/api/models`, save into `localStorage["note-maker-config-v1"]`, and restore on reload.
+3. Add a custom question, edit it, delete it, reload with saved custom questions, and reset back to template-only state.
+4. Select saved style/session history, assert the open button enables, clear disables it, and opening fetches `/api/author-style/{id}` or `/api/brief-sessions/{id}` as needed.
+5. Assert non-empty style and brief cards render `.artifact-card-header`, `.artifact-meta`, and `.artifact-section`, while raw Markdown/JSON previews remain populated.
+
+## Commands
+
+```sh
+go test ./static ./cmd/server
+go test ./...
+```
+
+## Review Result
+
+Commands passed after aligning the parallel history test fixture with the selected output-format validator:
+
+```sh
+go test ./cmd/server ./internal/handlers ./static
+go test ./internal/handlers ./static
+go test ./static ./cmd/server
+go test ./...
+node --check static/js/script.js
+git diff --check
+```
+
+This browser-contract document is still a validation checkpoint, not a close signal for #13. The next cut should add browser E2E over stubbed API responses once the project has a Playwright or equivalent harness.
+
+## Issue Policy
+
+- #13: keep open. Contract coverage is useful, but the issue asks for browser E2E.
+- #14: keep open. SQLite exists, but queryable product memory is not fully exposed.
+- #27: keep open unless the owner explicitly splits and closes the first saved-history picker cut.
+- #28: keep open unless the owner explicitly splits and closes the first readable-card cut.
diff --git a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
index 67b27be..23e6006 100644
--- a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
+++ b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
@@ -1,7 +1,7 @@
# Issue 27/28 history and artifact UI/API validation
Date: 2026-05-03
-Branch: `codex/issue27-28-history-artifacts`
+Branch: `codex/issue27-28-history-artifacts`; reviewed again from `codex/phase-c-history-e2e`
## Scope
@@ -61,6 +61,27 @@ ok github.com/teradakousuke/note_maker/internal/handlers (cached)
ok github.com/teradakousuke/note_maker/static (cached)
```
+Follow-up review on `codex/phase-c-history-e2e`:
+
+```sh
+go test ./...
+node --check static/js/script.js
+git diff --check
+```
+
+These passed after the project/article/draft history follow-up was integrated. The follow-up adds SQLite-backed read routes and UI contract coverage, but it is still browser-contract coverage rather than a real browser E2E close signal for #13.
+
+Final follow-up validation after the fixture alignment:
+
+```sh
+go test ./...
+go test ./cmd/server ./internal/handlers ./static
+node --check static/js/script.js
+git diff --check
+```
+
+All passed. Project/article/draft history can continue as implementation work, but #13 still needs real browser E2E before it closes.
+
## Acceptance Status
- Saved style guides can be listed for picker UIs: done.
@@ -75,5 +96,5 @@ ok github.com/teradakousuke/note_maker/static (cached)
- Add-persona authoring UI is still unimplemented.
- Broader edit persistence beyond the existing fork-on-edit/session save flow is still unimplemented.
-- Project/article/draft history browsing from SQLite remains unimplemented in the UI.
-- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13).
+- Project/article/draft history browsing from SQLite has a follow-up implementation through read APIs and history cards. Treat it as a separate #83 product-readiness cut from the original #27/#28 first-cut validation.
+- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13). Static contract tests alone are not enough to close #13.
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index ef1d278..cd8182a 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -42,6 +42,25 @@ type workflowStoreBackend interface {
GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool)
}
+type workflowHistoryReader interface {
+ GetProject(string) (sqliterepo.ProjectRecord, bool)
+ ListProjects() ([]sqliterepo.ProjectRecord, error)
+ GetArticle(string) (sqliterepo.ArticleRecord, bool)
+ ListArticlesByProject(string) ([]sqliterepo.ArticleRecord, error)
+ ListSourceSnapshots(string, string) ([]sqliterepo.SourceSnapshotRecord, error)
+ GetDraft(string) (sqliterepo.DraftRecord, bool)
+ ListDrafts(string) ([]sqliterepo.DraftRecord, error)
+ ListSectionRegenerations(string) ([]sqliterepo.SectionRegenerationRecord, error)
+}
+
+type workflowHistoryWriter interface {
+ workflowHistoryReader
+ SaveProject(sqliterepo.ProjectRecord) error
+ SaveArticle(sqliterepo.ArticleRecord) error
+ SaveDraft(sqliterepo.DraftRecord) error
+ SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord) error
+}
+
func newWorkflowStore() workflowStoreBackend {
config := resolveWorkflowStorageConfig()
setActiveWorkflowStorage(config)
@@ -179,6 +198,101 @@ type workflowArtifactsResponse struct {
StyleGuides []styleGuideArtifactResponse `json:"style_guides"`
Sessions []briefSessionSummaryResponse `json:"sessions"`
Briefs []briefArtifactResponse `json:"briefs"`
+ Projects []projectHistoryResponse `json:"projects,omitempty"`
+ Articles []articleHistoryResponse `json:"articles,omitempty"`
+ Drafts []draftHistoryResponse `json:"drafts,omitempty"`
+}
+
+type projectHistoryListResponse struct {
+ Projects []projectHistoryResponse `json:"projects"`
+}
+
+type projectHistoryDetailResponse struct {
+ Project projectHistoryResponse `json:"project"`
+ Articles []articleHistoryResponse `json:"articles"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+}
+
+type articleHistoryDetailResponse struct {
+ Article articleHistoryResponse `json:"article"`
+ Drafts []draftHistoryResponse `json:"drafts"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+}
+
+type draftHistoryDetailResponse struct {
+ Draft draftHistoryResponse `json:"draft"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+ SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations"`
+}
+
+type projectHistoryResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Articles []articleHistoryResponse `json:"articles,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+}
+
+type articleHistoryResponse struct {
+ ID string `json:"id"`
+ ProjectID string `json:"project_id,omitempty"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ BriefSessionID string `json:"brief_session_id,omitempty"`
+ CurrentDraftID string `json:"current_draft_id,omitempty"`
+ Title string `json:"title,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Drafts []draftHistoryResponse `json:"drafts,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+}
+
+type sourceSnapshotHistoryResponse struct {
+ ID string `json:"id"`
+ ScopeType string `json:"scope_type"`
+ ScopeID string `json:"scope_id"`
+ Selector any `json:"selector"`
+ Profile any `json:"profile,omitempty"`
+ Article any `json:"article,omitempty"`
+ ContentHash string `json:"content_hash,omitempty"`
+ FetchedAt string `json:"fetched_at,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+}
+
+type draftHistoryResponse struct {
+ ID string `json:"id"`
+ ArticleID string `json:"article_id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ StyleProfileID string `json:"style_profile_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ OutputFormatID string `json:"output_format_id,omitempty"`
+ Version int `json:"version"`
+ Markdown string `json:"markdown"`
+ ContentHash string `json:"content_hash,omitempty"`
+ Evaluation draftapp.StyleEvaluation `json:"evaluation"`
+ Verification draftapp.FinalVerification `json:"verification"`
+ QuestionTemplateVersion string `json:"question_template_version,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+ SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations,omitempty"`
+}
+
+type sectionRegenerationHistoryResponse struct {
+ ID string `json:"id"`
+ DraftID string `json:"draft_id"`
+ ArticleID string `json:"article_id,omitempty"`
+ SectionAnchor string `json:"section_anchor"`
+ SectionHeading string `json:"section_heading,omitempty"`
+ BaseVersion int `json:"base_version"`
+ Version int `json:"version"`
+ ReplacementMarkdown string `json:"replacement_markdown"`
+ UpdatedDraftMarkdown string `json:"updated_draft_markdown"`
+ UpdatedContentHash string `json:"updated_content_hash,omitempty"`
+ Verification draftapp.FinalVerification `json:"verification"`
+ CreatedAt string `json:"created_at,omitempty"`
}
type briefSessionTemplateResponse struct {
@@ -273,6 +387,7 @@ type regenerateDraftSectionResponse struct {
Section draftapp.MarkdownSection `json:"section"`
ReplacementMarkdown string `json:"replacement_markdown"`
UpdatedDraftMarkdown string `json:"updated_draft_markdown"`
+ RegenerationID string `json:"regeneration_id,omitempty"`
}
func toGenerateDraftResponse(result draftapp.GenerateResult, draftPath string) generateDraftResponse {
@@ -833,10 +948,128 @@ func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) {
}
sortAuthorStyles(styles)
sortBriefSessions(sessions)
- respondWithJSON(w, http.StatusOK, workflowArtifactsResponse{
+ response := workflowArtifactsResponse{
StyleGuides: toStyleGuideArtifactResponses(styles),
Sessions: toBriefSessionSummaryResponses(sessions, briefs),
Briefs: listBriefArtifactResponses(briefs),
+ }
+ if store, ok := workflowStore.(workflowHistoryReader); ok {
+ if projects, articles, drafts, err := listWorkflowHistoryResponses(store); err != nil {
+ respondWithError(w, "WORKFLOW_HISTORY_LIST_FAILED", "Failed to list project history", err.Error(), http.StatusInternalServerError)
+ return
+ } else {
+ response.Projects = projects
+ response.Articles = articles
+ response.Drafts = drafts
+ }
+ }
+ respondWithJSON(w, http.StatusOK, response)
+}
+
+// ListProjectsHandler returns SQLite-backed project history when available.
+func ListProjectsHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: []projectHistoryResponse{}})
+ return
+ }
+ projects, err := store.ListProjects()
+ if err != nil {
+ respondWithError(w, "PROJECT_LIST_FAILED", "Failed to list projects", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: toProjectHistoryResponses(projects)})
+}
+
+// GetProjectHandler returns one project with its article and source history.
+func GetProjectHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", "", http.StatusNotFound)
+ return
+ }
+ projectID := pathValue(r, "id")
+ project, ok := store.GetProject(projectID)
+ if !ok {
+ respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", projectID, http.StatusNotFound)
+ return
+ }
+ articles, err := store.ListArticlesByProject(projectID)
+ if err != nil {
+ respondWithError(w, "PROJECT_ARTICLES_LIST_FAILED", "Failed to list project articles", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sourceSnapshots, err := store.ListSourceSnapshots("project", projectID)
+ if err != nil {
+ respondWithError(w, "PROJECT_SOURCE_SNAPSHOT_LIST_FAILED", "Failed to list project source snapshots", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ articleResponses := make([]articleHistoryResponse, 0, len(articles))
+ for _, article := range articles {
+ response, _, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ respondWithError(w, "PROJECT_ARTICLE_HISTORY_FAILED", "Failed to load project article history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ articleResponses = append(articleResponses, response)
+ }
+ projectResponse := toProjectHistoryResponse(project)
+ projectResponse.Articles = articleResponses
+ projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(sourceSnapshots)
+ respondWithJSON(w, http.StatusOK, projectHistoryDetailResponse{
+ Project: projectResponse,
+ Articles: articleResponses,
+ SourceSnapshots: projectResponse.SourceSnapshots,
+ })
+}
+
+// GetArticleHandler returns one article with draft versions and source history.
+func GetArticleHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", "", http.StatusNotFound)
+ return
+ }
+ articleID := pathValue(r, "id")
+ article, ok := store.GetArticle(articleID)
+ if !ok {
+ respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", articleID, http.StatusNotFound)
+ return
+ }
+ articleResponse, _, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ respondWithError(w, "ARTICLE_HISTORY_FAILED", "Failed to load article history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, articleHistoryDetailResponse{
+ Article: articleResponse,
+ Drafts: articleResponse.Drafts,
+ SourceSnapshots: articleResponse.SourceSnapshots,
+ })
+}
+
+// GetDraftHandler returns one draft with regeneration history.
+func GetDraftHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", "", http.StatusNotFound)
+ return
+ }
+ draftID := pathValue(r, "id")
+ draft, ok := store.GetDraft(draftID)
+ if !ok {
+ respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", draftID, http.StatusNotFound)
+ return
+ }
+ draftResponse, err := draftHistoryResponseWithDetails(store, draft)
+ if err != nil {
+ respondWithError(w, "DRAFT_HISTORY_FAILED", "Failed to load draft history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, draftHistoryDetailResponse{
+ Draft: draftResponse,
+ SourceSnapshots: draftResponse.SourceSnapshots,
+ SectionRegenerations: draftResponse.SectionRegenerations,
})
}
@@ -1030,7 +1263,8 @@ func GenerateDraftHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "DRAFT_GENERATION_FAILED", "Failed to generate draft", err.Error(), http.StatusInternalServerError)
return
}
- respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, ""))
+ draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format)
+ respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, draftPath))
}
func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDraftRequest, profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) {
@@ -1086,7 +1320,8 @@ func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDra
_ = stream.Send("error", streamError{Code: "DRAFT_GENERATION_FAILED", Message: "Failed to generate draft", Detail: err.Error(), ElapsedMS: stream.ElapsedMS()})
return
}
- _ = stream.Send("result", toGenerateDraftResponse(result, ""))
+ draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format)
+ _ = stream.Send("result", toGenerateDraftResponse(result, draftPath))
_ = stream.Send("done", streamStatus{Status: "completed", Phase: "draft", Endpoint: endpoint, Model: model, StartedAt: stream.started.Format(time.RFC3339), ElapsedMS: stream.ElapsedMS(), Runes: len([]rune(result.Draft.Markdown())), Score: result.Evaluation.Comparison.Score})
}
@@ -1097,8 +1332,27 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest)
return
}
+ pathID := pathValue(r, "id")
+ baseDraft, hasBaseDraft := historyDraftFromPath(pathID)
+ if hasBaseDraft {
+ if strings.TrimSpace(req.SessionID) == "" {
+ req.SessionID = baseDraft.SessionID
+ }
+ if strings.TrimSpace(req.StyleProfileID) == "" {
+ req.StyleProfileID = baseDraft.StyleProfileID
+ }
+ if strings.TrimSpace(req.PersonaID) == "" {
+ req.PersonaID = baseDraft.PersonaID
+ }
+ if strings.TrimSpace(req.OutputFormatID) == "" {
+ req.OutputFormatID = baseDraft.OutputFormatID
+ }
+ if strings.TrimSpace(req.DraftMarkdown) == "" {
+ req.DraftMarkdown = baseDraft.Markdown
+ }
+ }
if strings.TrimSpace(req.SessionID) == "" {
- req.SessionID = pathValue(r, "id")
+ req.SessionID = pathID
}
profile, guide, articleBrief, persona, format, ok := draftContextFromRequest(req.StyleProfileID, req.SessionID, req.PersonaID, req.OutputFormatID)
if !ok {
@@ -1126,10 +1380,12 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "DRAFT_SECTION_REGENERATE_FAILED", "Failed to regenerate draft section", err.Error(), http.StatusBadRequest)
return
}
+ regenerationID := saveSectionRegenerationHistory(baseDraft, hasBaseDraft, req, result)
respondWithJSON(w, http.StatusOK, regenerateDraftSectionResponse{
Section: result.Section,
ReplacementMarkdown: result.ReplacementMarkdown,
UpdatedDraftMarkdown: result.UpdatedDraftMarkdown,
+ RegenerationID: regenerationID,
})
}
@@ -1174,6 +1430,153 @@ func draftContextFromRequest(styleProfileID, sessionID, personaID, formatID stri
return profile, guide, articleBrief, persona, format, true
}
+func historyDraftFromPath(pathID string) (sqliterepo.DraftRecord, bool) {
+ if strings.TrimSpace(pathID) == "" {
+ return sqliterepo.DraftRecord{}, false
+ }
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ return sqliterepo.DraftRecord{}, false
+ }
+ return store.GetDraft(pathID)
+}
+
+func saveGeneratedDraftHistory(req generateDraftRequest, result draftapp.GenerateResult, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) string {
+ store, ok := workflowStore.(workflowHistoryWriter)
+ if !ok {
+ return ""
+ }
+ sessionID := strings.TrimSpace(req.SessionID)
+ if sessionID == "" {
+ return ""
+ }
+ now := time.Now().UTC()
+ projectID := historyRecordID("project", sessionID)
+ articleID := historyRecordID("article", sessionID)
+ title := briefTitle(sessionID, articleBrief)
+ projectName := firstNonEmpty(title, sessionID)
+
+ project := sqliterepo.ProjectRecord{
+ ID: projectID,
+ Name: projectName,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Metadata: map[string]any{"session_id": sessionID},
+ }
+ if existing, ok := store.GetProject(projectID); ok {
+ project.CreatedAt = existing.CreatedAt
+ if strings.TrimSpace(existing.Name) != "" {
+ project.Name = existing.Name
+ }
+ project.Metadata = mergeHistoryMetadata(existing.Metadata, project.Metadata)
+ }
+ if err := store.SaveProject(project); err != nil {
+ return ""
+ }
+
+ drafts, err := store.ListDrafts(articleID)
+ if err != nil {
+ return ""
+ }
+ draftID := newID("draft")
+ version := len(drafts) + 1
+ article := sqliterepo.ArticleRecord{
+ ID: articleID,
+ ProjectID: projectID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ BriefSessionID: sessionID,
+ CurrentDraftID: draftID,
+ Title: title,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Metadata: map[string]any{"session_id": sessionID},
+ }
+ if existing, ok := store.GetArticle(articleID); ok {
+ article.CreatedAt = existing.CreatedAt
+ article.Metadata = mergeHistoryMetadata(existing.Metadata, article.Metadata)
+ }
+ if err := store.SaveArticle(article); err != nil {
+ return ""
+ }
+ draft := sqliterepo.DraftRecord{
+ ID: draftID,
+ ArticleID: articleID,
+ SessionID: sessionID,
+ StyleProfileID: firstNonEmpty(req.StyleProfileID, articleBrief.StyleProfileID),
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Version: version,
+ Markdown: result.Draft.Markdown(),
+ Evaluation: result.Evaluation,
+ Verification: result.Verification,
+ CreatedAt: now,
+ }
+ if err := store.SaveDraft(draft); err != nil {
+ return ""
+ }
+ return draftID
+}
+
+func saveSectionRegenerationHistory(baseDraft sqliterepo.DraftRecord, hasBaseDraft bool, req regenerateDraftSectionRequest, result draftapp.RegenerateSectionResult) string {
+ if !hasBaseDraft {
+ return ""
+ }
+ store, ok := workflowStore.(workflowHistoryWriter)
+ if !ok {
+ return ""
+ }
+ regenerations, err := store.ListSectionRegenerations(baseDraft.ID)
+ if err != nil {
+ return ""
+ }
+ now := time.Now().UTC()
+ record := sqliterepo.SectionRegenerationRecord{
+ ID: newID("regen"),
+ DraftID: baseDraft.ID,
+ ArticleID: baseDraft.ArticleID,
+ SectionAnchor: firstNonEmpty(req.SectionAnchor, result.Section.Anchor),
+ SectionHeading: result.Section.Heading,
+ BaseVersion: baseDraft.Version,
+ Version: len(regenerations) + baseDraft.Version + 1,
+ ReplacementMarkdown: result.ReplacementMarkdown,
+ UpdatedDraftMarkdown: result.UpdatedDraftMarkdown,
+ CreatedAt: now,
+ }
+ if record.BaseVersion <= 0 {
+ record.BaseVersion = 1
+ }
+ if record.Version <= record.BaseVersion {
+ record.Version = record.BaseVersion + 1
+ }
+ if err := store.SaveSectionRegeneration(record); err != nil {
+ return ""
+ }
+ return record.ID
+}
+
+func historyRecordID(prefix, seed string) string {
+ seed = strings.TrimSpace(seed)
+ if seed == "" {
+ return newID(prefix)
+ }
+ return prefix + "_" + hex.EncodeToString([]byte(seed))
+}
+
+func mergeHistoryMetadata(existing, next map[string]any) map[string]any {
+ if len(existing) == 0 {
+ return next
+ }
+ merged := make(map[string]any, len(existing)+len(next))
+ for key, value := range existing {
+ merged[key] = value
+ }
+ for key, value := range next {
+ merged[key] = value
+ }
+ return merged
+}
+
func newDraftServiceWithVerifier(generator draftapp.TextGenerator, model string) (*draftapp.Service, error) {
verifierClient, err := llamacpp.NewClientFromEnvForPurposeWithModel("VERIFY", model)
if err != nil {
@@ -1427,6 +1830,184 @@ func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) {
})
}
+func toProjectHistoryResponses(projects []sqliterepo.ProjectRecord) []projectHistoryResponse {
+ items := make([]projectHistoryResponse, 0, len(projects))
+ for _, project := range projects {
+ items = append(items, toProjectHistoryResponse(project))
+ }
+ return items
+}
+
+func toProjectHistoryResponse(project sqliterepo.ProjectRecord) projectHistoryResponse {
+ return projectHistoryResponse{
+ ID: project.ID,
+ Name: project.Name,
+ CreatedAt: formatOptionalTime(project.CreatedAt),
+ UpdatedAt: formatOptionalTime(project.UpdatedAt),
+ Metadata: project.Metadata,
+ }
+}
+
+func listWorkflowHistoryResponses(store workflowHistoryReader) ([]projectHistoryResponse, []articleHistoryResponse, []draftHistoryResponse, error) {
+ projects, err := store.ListProjects()
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponses := make([]projectHistoryResponse, 0, len(projects))
+ articleResponses := []articleHistoryResponse{}
+ draftResponses := []draftHistoryResponse{}
+ for _, project := range projects {
+ projectResponse := toProjectHistoryResponse(project)
+ snapshots, err := store.ListSourceSnapshots("project", project.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ articles, err := store.ListArticlesByProject(project.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.Articles = make([]articleHistoryResponse, 0, len(articles))
+ for _, article := range articles {
+ articleResponse, drafts, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.Articles = append(projectResponse.Articles, articleResponse)
+ articleResponses = append(articleResponses, articleResponse)
+ draftResponses = append(draftResponses, drafts...)
+ }
+ projectResponses = append(projectResponses, projectResponse)
+ }
+ return projectResponses, articleResponses, draftResponses, nil
+}
+
+func articleHistoryResponseWithDetails(store workflowHistoryReader, article sqliterepo.ArticleRecord) (articleHistoryResponse, []draftHistoryResponse, error) {
+ articleResponse := toArticleHistoryResponse(article)
+ snapshots, err := store.ListSourceSnapshots("article", article.ID)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ articleResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ drafts, err := store.ListDrafts(article.ID)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ draftResponses := make([]draftHistoryResponse, 0, len(drafts))
+ for _, draft := range drafts {
+ draftResponse, err := draftHistoryResponseWithDetails(store, draft)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ draftResponses = append(draftResponses, draftResponse)
+ }
+ articleResponse.Drafts = draftResponses
+ return articleResponse, draftResponses, nil
+}
+
+func draftHistoryResponseWithDetails(store workflowHistoryReader, draft sqliterepo.DraftRecord) (draftHistoryResponse, error) {
+ draftResponse := toDraftHistoryResponse(draft)
+ snapshots, err := store.ListSourceSnapshots("draft", draft.ID)
+ if err != nil {
+ return draftHistoryResponse{}, err
+ }
+ draftResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ regenerations, err := store.ListSectionRegenerations(draft.ID)
+ if err != nil {
+ return draftHistoryResponse{}, err
+ }
+ draftResponse.SectionRegenerations = toSectionRegenerationHistoryResponses(regenerations)
+ return draftResponse, nil
+}
+
+func toArticleHistoryResponse(article sqliterepo.ArticleRecord) articleHistoryResponse {
+ return articleHistoryResponse{
+ ID: article.ID,
+ ProjectID: article.ProjectID,
+ PersonaID: article.PersonaID,
+ OutputFormatID: article.OutputFormatID,
+ BriefSessionID: article.BriefSessionID,
+ CurrentDraftID: article.CurrentDraftID,
+ Title: article.Title,
+ CreatedAt: formatOptionalTime(article.CreatedAt),
+ UpdatedAt: formatOptionalTime(article.UpdatedAt),
+ Metadata: article.Metadata,
+ }
+}
+
+func toSourceSnapshotHistoryResponses(snapshots []sqliterepo.SourceSnapshotRecord) []sourceSnapshotHistoryResponse {
+ items := make([]sourceSnapshotHistoryResponse, 0, len(snapshots))
+ for _, snapshot := range snapshots {
+ items = append(items, toSourceSnapshotHistoryResponse(snapshot))
+ }
+ return items
+}
+
+func toSourceSnapshotHistoryResponse(snapshot sqliterepo.SourceSnapshotRecord) sourceSnapshotHistoryResponse {
+ return sourceSnapshotHistoryResponse{
+ ID: snapshot.ID,
+ ScopeType: snapshot.ScopeType,
+ ScopeID: snapshot.ScopeID,
+ Selector: snapshot.Selector,
+ Profile: snapshot.Profile,
+ Article: snapshot.Article,
+ ContentHash: snapshot.ContentHash,
+ FetchedAt: formatOptionalTime(snapshot.FetchedAt),
+ CreatedAt: formatOptionalTime(snapshot.CreatedAt),
+ }
+}
+
+func toDraftHistoryResponses(drafts []sqliterepo.DraftRecord) []draftHistoryResponse {
+ items := make([]draftHistoryResponse, 0, len(drafts))
+ for _, draft := range drafts {
+ items = append(items, toDraftHistoryResponse(draft))
+ }
+ return items
+}
+
+func toDraftHistoryResponse(draft sqliterepo.DraftRecord) draftHistoryResponse {
+ return draftHistoryResponse{
+ ID: draft.ID,
+ ArticleID: draft.ArticleID,
+ SessionID: draft.SessionID,
+ StyleProfileID: draft.StyleProfileID,
+ PersonaID: draft.PersonaID,
+ OutputFormatID: draft.OutputFormatID,
+ Version: draft.Version,
+ Markdown: draft.Markdown,
+ ContentHash: draft.ContentHash,
+ Evaluation: draft.Evaluation,
+ Verification: draft.Verification,
+ QuestionTemplateVersion: draft.QuestionTemplateVersion,
+ CreatedAt: formatOptionalTime(draft.CreatedAt),
+ }
+}
+
+func toSectionRegenerationHistoryResponses(regenerations []sqliterepo.SectionRegenerationRecord) []sectionRegenerationHistoryResponse {
+ items := make([]sectionRegenerationHistoryResponse, 0, len(regenerations))
+ for _, regeneration := range regenerations {
+ items = append(items, toSectionRegenerationHistoryResponse(regeneration))
+ }
+ return items
+}
+
+func toSectionRegenerationHistoryResponse(regeneration sqliterepo.SectionRegenerationRecord) sectionRegenerationHistoryResponse {
+ return sectionRegenerationHistoryResponse{
+ ID: regeneration.ID,
+ DraftID: regeneration.DraftID,
+ ArticleID: regeneration.ArticleID,
+ SectionAnchor: regeneration.SectionAnchor,
+ SectionHeading: regeneration.SectionHeading,
+ BaseVersion: regeneration.BaseVersion,
+ Version: regeneration.Version,
+ ReplacementMarkdown: regeneration.ReplacementMarkdown,
+ UpdatedDraftMarkdown: regeneration.UpdatedDraftMarkdown,
+ UpdatedContentHash: regeneration.UpdatedContentHash,
+ Verification: regeneration.Verification,
+ CreatedAt: formatOptionalTime(regeneration.CreatedAt),
+ }
+}
+
func listBriefArtifactResponses(briefs map[string]briefdomain.ArticleBrief) []briefArtifactResponse {
sessionIDs := make([]string, 0, len(briefs))
for sessionID := range briefs {
diff --git a/internal/handlers/workflow_history_api_test.go b/internal/handlers/workflow_history_api_test.go
new file mode 100644
index 0000000..fc1a11b
--- /dev/null
+++ b/internal/handlers/workflow_history_api_test.go
@@ -0,0 +1,452 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+ draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
+ briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+ "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+ sqliterepo "github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite"
+)
+
+func TestListProjectsHandlerReturnsSQLiteProjects(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ response := httptest.NewRecorder()
+ ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ payload := decodeHistoryAPIPayload(t, response)
+ projects := historyAPIArray(t, payload, "projects")
+ if len(projects) != 1 {
+ t.Fatalf("projects = %d, want 1: %#v", len(projects), payload)
+ }
+ project := historyAPIObject(t, projects[0])
+ if got := historyAPIString(project, "id", "ID"); got != "project-history" {
+ t.Fatalf("project id = %q, want project-history: %#v", got, project)
+ }
+ if got := historyAPIString(project, "name", "Name"); got != "History project" {
+ t.Fatalf("project name = %q, want History project: %#v", got, project)
+ }
+}
+
+func TestListProjectsHandlerReturnsEmptyListForUnsupportedStore(t *testing.T) {
+ previous := workflowStore
+ workflowStore = memory.NewWorkflowStore()
+ t.Cleanup(func() { workflowStore = previous })
+
+ response := httptest.NewRecorder()
+ ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ payload := decodeHistoryAPIPayload(t, response)
+ if projects := historyAPIArray(t, payload, "projects"); len(projects) != 0 {
+ t.Fatalf("projects = %d, want 0: %#v", len(projects), payload)
+ }
+}
+
+func TestListWorkflowArtifactsHandlerIncludesSQLiteHistory(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ response := httptest.NewRecorder()
+ ListWorkflowArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/workflow/artifacts", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload workflowArtifactsResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.Projects) != 1 || payload.Projects[0].ID != "project-history" {
+ t.Fatalf("unexpected projects: %#v", payload.Projects)
+ }
+ if len(payload.Articles) != 1 || payload.Articles[0].ID != "article-history" {
+ t.Fatalf("unexpected articles: %#v", payload.Articles)
+ }
+ if len(payload.Drafts) != 1 || payload.Drafts[0].ID != "draft-history" {
+ t.Fatalf("unexpected drafts: %#v", payload.Drafts)
+ }
+ if len(payload.Projects[0].Articles) != 1 || len(payload.Articles[0].Drafts) != 1 || len(payload.Drafts[0].SectionRegenerations) != 1 {
+ t.Fatalf("workflow artifacts missing nested history: %#v", payload)
+ }
+}
+
+func TestGetProjectHandlerReturnsArticlesAndSourceSnapshots(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/projects/project-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "project-history"})
+ response := httptest.NewRecorder()
+ GetProjectHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ project := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "project")
+ if got := historyAPIString(project, "id", "ID"); got != "project-history" {
+ t.Fatalf("project id = %q, want project-history: %#v", got, project)
+ }
+ articles := historyAPIArrayFromObject(t, project, "articles")
+ if len(articles) != 1 || historyAPIString(historyAPIObject(t, articles[0]), "id", "ID") != "article-history" {
+ t.Fatalf("unexpected project articles: %#v", articles)
+ }
+ snapshots := historyAPIArrayFromObject(t, project, "source_snapshots", "SourceSnapshots")
+ if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-project" {
+ t.Fatalf("unexpected project source snapshots: %#v", snapshots)
+ }
+}
+
+func TestGetArticleHandlerReturnsDraftsAndSourceSnapshots(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/articles/article-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "article-history"})
+ response := httptest.NewRecorder()
+ GetArticleHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ article := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "article")
+ if got := historyAPIString(article, "id", "ID"); got != "article-history" {
+ t.Fatalf("article id = %q, want article-history: %#v", got, article)
+ }
+ drafts := historyAPIArrayFromObject(t, article, "drafts")
+ if len(drafts) != 1 || historyAPIString(historyAPIObject(t, drafts[0]), "id", "ID") != "draft-history" {
+ t.Fatalf("unexpected article drafts: %#v", drafts)
+ }
+ snapshots := historyAPIArrayFromObject(t, article, "source_snapshots", "SourceSnapshots")
+ if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-article" {
+ t.Fatalf("unexpected article source snapshots: %#v", snapshots)
+ }
+}
+
+func TestGetDraftHandlerReturnsSectionRegenerations(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/drafts/draft-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "draft-history"})
+ response := httptest.NewRecorder()
+ GetDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ draft := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "draft")
+ if got := historyAPIString(draft, "id", "ID"); got != "draft-history" {
+ t.Fatalf("draft id = %q, want draft-history: %#v", got, draft)
+ }
+ if got := historyAPIString(draft, "markdown", "Markdown"); got != "# History draft\n\nOriginal body." {
+ t.Fatalf("draft markdown = %q, want seeded markdown: %#v", got, draft)
+ }
+ regenerations := historyAPIArrayFromObject(t, draft, "section_regenerations", "SectionRegenerations")
+ if len(regenerations) != 1 {
+ t.Fatalf("section regenerations = %d, want 1: %#v", len(regenerations), regenerations)
+ }
+ regeneration := historyAPIObject(t, regenerations[0])
+ if got := historyAPIString(regeneration, "section_anchor", "SectionAnchor"); got != "history-anchor" {
+ t.Fatalf("section anchor = %q, want history-anchor: %#v", got, regeneration)
+ }
+}
+
+func TestHistoryReadHandlersReturnNotFoundForMissingIDs(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ tests := []struct {
+ name string
+ target string
+ handler http.HandlerFunc
+ }{
+ {name: "project", target: "/api/projects/missing", handler: GetProjectHandler},
+ {name: "article", target: "/api/articles/missing", handler: GetArticleHandler},
+ {name: "draft", target: "/api/drafts/missing", handler: GetDraftHandler},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, tt.target, nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ tt.handler(response, request)
+
+ if response.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404, body = %s", response.Code, response.Body.String())
+ }
+ })
+ }
+}
+
+func TestSaveGeneratedDraftHistoryPersistsDraftAndRegeneration(t *testing.T) {
+ store := setupEmptyHistoryAPIStore(t)
+ persona, ok := personadomain.DefaultRegistry().Get(personadomain.IDCloudia)
+ if !ok {
+ t.Fatal("missing cloudia persona")
+ }
+ format, ok := outputformat.DefaultRegistry().Get(outputformat.IDNoteArticle)
+ if !ok {
+ t.Fatal("missing note format")
+ }
+ generatedDraft, err := articledomain.NewDraftForFormat("# Saved draft\n\nOriginal body.", format.ID)
+ if err != nil {
+ t.Fatalf("new generated draft: %v", err)
+ }
+
+ draftID := saveGeneratedDraftHistory(generateDraftRequest{
+ StyleProfileID: "style-save",
+ SessionID: "session-save",
+ }, draftapp.GenerateResult{
+ Draft: generatedDraft,
+ Evaluation: draftapp.StyleEvaluation{
+ Passed: true,
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "ok",
+ },
+ }, briefdomain.ArticleBrief{
+ Theme: "Saved draft",
+ StyleProfileID: "style-save",
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ }, persona, format)
+ if draftID == "" {
+ t.Fatal("draft history id was empty")
+ }
+
+ articleID := historyRecordID("article", "session-save")
+ article, ok := store.GetArticle(articleID)
+ if !ok || article.CurrentDraftID != draftID {
+ t.Fatalf("saved article = %#v ok=%v, want current draft %s", article, ok, draftID)
+ }
+ savedDraft, ok := store.GetDraft(draftID)
+ if !ok || savedDraft.ArticleID != articleID || savedDraft.Version != 1 {
+ t.Fatalf("saved draft = %#v ok=%v", savedDraft, ok)
+ }
+
+ regenerationID := saveSectionRegenerationHistory(savedDraft, true, regenerateDraftSectionRequest{
+ SectionAnchor: "saved",
+ }, draftapp.RegenerateSectionResult{
+ Section: draftapp.MarkdownSection{Anchor: "saved", Heading: "Saved"},
+ ReplacementMarkdown: "## Saved\n\nUpdated body.",
+ UpdatedDraftMarkdown: "# Saved draft\n\nUpdated body.",
+ })
+ if regenerationID == "" {
+ t.Fatal("section regeneration history id was empty")
+ }
+ regenerations, err := store.ListSectionRegenerations(draftID)
+ if err != nil {
+ t.Fatalf("list section regenerations: %v", err)
+ }
+ if len(regenerations) != 1 || regenerations[0].ID != regenerationID || regenerations[0].Version != 2 {
+ t.Fatalf("unexpected regenerations: %#v", regenerations)
+ }
+}
+
+func setupHistoryAPIStore(t *testing.T) {
+ t.Helper()
+ store := setupEmptyHistoryAPIStore(t)
+ seedHistoryAPIStore(t, store)
+}
+
+func setupEmptyHistoryAPIStore(t *testing.T) *sqliterepo.WorkflowStore {
+ t.Helper()
+ previous := workflowStore
+ db, err := sql.Open("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("open sqlite db: %v", err)
+ }
+ store, err := sqliterepo.NewWorkflowStoreDB(db)
+ if err != nil {
+ _ = db.Close()
+ t.Fatalf("new sqlite workflow store: %v", err)
+ }
+ t.Cleanup(func() {
+ workflowStore = previous
+ _ = store.Close()
+ })
+ workflowStore = store
+ return store
+}
+
+func seedHistoryAPIStore(t *testing.T, store *sqliterepo.WorkflowStore) {
+ t.Helper()
+ now := time.Unix(1710000000, 0).UTC()
+ project := sqliterepo.ProjectRecord{
+ ID: "project-history",
+ Name: "History project",
+ CreatedAt: now,
+ UpdatedAt: now.Add(2 * time.Minute),
+ Metadata: map[string]any{"owner": "history-test"},
+ }
+ if err := store.SaveProject(project); err != nil {
+ t.Fatalf("save project: %v", err)
+ }
+ article := sqliterepo.ArticleRecord{
+ ID: "article-history",
+ ProjectID: project.ID,
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ BriefSessionID: "brief-history",
+ Title: "History article",
+ CreatedAt: now,
+ UpdatedAt: now.Add(time.Minute),
+ }
+ if err := store.SaveArticle(article); err != nil {
+ t.Fatalf("save article: %v", err)
+ }
+ if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{
+ ID: "snapshot-project",
+ ScopeType: "project",
+ ScopeID: project.ID,
+ Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: "cloudia"},
+ Profile: &sourcedomain.ProfileSnapshot{
+ Kind: sourcedomain.KindZenn,
+ Ref: "cloudia",
+ Title: "Cloudia",
+ FetchedAt: now,
+ },
+ FetchedAt: now,
+ CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("save project source snapshot: %v", err)
+ }
+ if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{
+ ID: "snapshot-article",
+ ScopeType: "article",
+ ScopeID: article.ID,
+ Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: "https://zenn.dev/cloudia/articles/history"},
+ Article: &sourcedomain.ArticleSnapshot{
+ ID: "source-history",
+ Kind: sourcedomain.KindZenn,
+ URL: "https://zenn.dev/cloudia/articles/history",
+ Title: "Source history",
+ Content: "Source body",
+ FetchedAt: now,
+ },
+ FetchedAt: now,
+ CreatedAt: now.Add(time.Second),
+ }); err != nil {
+ t.Fatalf("save article source snapshot: %v", err)
+ }
+ draft := sqliterepo.DraftRecord{
+ ID: "draft-history",
+ ArticleID: article.ID,
+ SessionID: article.BriefSessionID,
+ StyleProfileID: "style-history",
+ PersonaID: article.PersonaID,
+ OutputFormatID: article.OutputFormatID,
+ Version: 1,
+ Markdown: "# History draft\n\nOriginal body.",
+ Evaluation: draftapp.StyleEvaluation{
+ Passed: true,
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "ok",
+ },
+ CreatedAt: now.Add(3 * time.Minute),
+ }
+ if err := store.SaveDraft(draft); err != nil {
+ t.Fatalf("save draft: %v", err)
+ }
+ if err := store.SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord{
+ ID: "regen-history",
+ DraftID: draft.ID,
+ ArticleID: article.ID,
+ SectionAnchor: "history-anchor",
+ SectionHeading: "History",
+ BaseVersion: 1,
+ Version: 2,
+ ReplacementMarkdown: "## History\n\nUpdated body.",
+ UpdatedDraftMarkdown: "# History draft\n\nUpdated body.",
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "regenerated ok",
+ },
+ CreatedAt: now.Add(4 * time.Minute),
+ }); err != nil {
+ t.Fatalf("save section regeneration: %v", err)
+ }
+}
+
+func decodeHistoryAPIPayload(t *testing.T, response *httptest.ResponseRecorder) any {
+ t.Helper()
+ var payload any
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ return payload
+}
+
+func historyAPIArray(t *testing.T, payload any, names ...string) []any {
+ t.Helper()
+ if array, ok := payload.([]any); ok {
+ return array
+ }
+ object := historyAPIObject(t, payload)
+ return historyAPIArrayFromObject(t, object, names...)
+}
+
+func historyAPINamedObject(t *testing.T, payload any, names ...string) map[string]any {
+ t.Helper()
+ object := historyAPIObject(t, payload)
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ return historyAPIObject(t, value)
+ }
+ }
+ return object
+}
+
+func historyAPIArrayFromObject(t *testing.T, object map[string]any, names ...string) []any {
+ t.Helper()
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ array, ok := value.([]any)
+ if !ok {
+ t.Fatalf("%s is %T, want array: %#v", name, value, object)
+ }
+ return array
+ }
+ }
+ t.Fatalf("none of %v found in response object: %#v", names, object)
+ return nil
+}
+
+func historyAPIObject(t *testing.T, value any) map[string]any {
+ t.Helper()
+ object, ok := value.(map[string]any)
+ if !ok {
+ t.Fatalf("value is %T, want object: %#v", value, value)
+ }
+ return object
+}
+
+func historyAPIString(object map[string]any, names ...string) string {
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ if text, ok := value.(string); ok {
+ return text
+ }
+ }
+ }
+ return ""
+}
diff --git a/static/css/style.css b/static/css/style.css
index c8086af..f91b597 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -193,7 +193,7 @@ body {
.history-picker-grid {
display: grid;
- grid-template-columns: minmax(160px, 0.65fr) minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@@ -221,6 +221,18 @@ body {
background: var(--warning-bg);
}
+.history-detail {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.history-detail .artifact-card {
+ margin: 0;
+ min-width: 0;
+}
+
.section-heading {
display: flex;
align-items: center;
@@ -704,6 +716,7 @@ pre {
.config-grid,
.history-picker-grid,
+ .history-detail,
.result-grid {
grid-template-columns: 1fr;
}
diff --git a/static/history_ui_test.go b/static/history_ui_test.go
index 76f87c5..f13634d 100644
--- a/static/history_ui_test.go
+++ b/static/history_ui_test.go
@@ -9,49 +9,427 @@ import (
"github.com/PuerkitoBio/goquery"
)
+type staticContract struct {
+ document *goquery.Document
+ script string
+}
+
func TestHistoryUIContract(t *testing.T) {
- index, err := os.ReadFile("index.html")
- if err != nil {
- t.Fatalf("read index: %v", err)
- }
- script, err := os.ReadFile("js/script.js")
- if err != nil {
- t.Fatalf("read script: %v", err)
- }
- document, err := goquery.NewDocumentFromReader(bytes.NewReader(index))
- if err != nil {
- t.Fatalf("parse index: %v", err)
- }
+ contract := loadStaticContract(t)
for _, selector := range []string{
"#history-persona-select",
+ "#history-project-select",
+ "#history-article-select",
+ "#history-draft-select",
"#history-style-select",
"#history-session-select",
"#refresh-history-btn",
"#open-history-btn",
"#clear-history-selection-btn",
"#history-status",
+ "#history-article-detail",
+ "#history-project-card",
+ "#history-article-card",
+ "#history-article-brief-card",
+ "#history-current-draft-card",
+ "#history-draft-versions-card",
+ "#history-source-snapshot-card",
"#style-guide-card",
"#brief-card",
} {
- if document.Find(selector).Length() != 1 {
- t.Fatalf("selector %s count = %d, want 1", selector, document.Find(selector).Length())
- }
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+
+ openHistoryButton := contract.document.Find("#open-history-btn")
+ if _, ok := openHistoryButton.Attr("disabled"); !ok {
+ t.Fatalf("#open-history-btn should start disabled until a history item is selected")
}
- source := string(script)
- for _, want := range []string{
+ assertScriptContains(t, contract.script, []string{
"const historyEndpoint = '/api/workflow/artifacts'",
"loadWorkflowHistory",
"normalizeWorkflowHistory",
+ "normalizeHistoryProject",
+ "normalizeHistoryArticle",
+ "normalizeHistoryDraft",
+ "renderHistoryArticleDetail",
+ "loadHistoryArticleDetail",
+ "loadHistoryDraftDetail",
"renderStyleGuideCard",
"renderBriefCard",
"requestJSON(`${historyEndpoint}?",
+ "/api/projects/",
+ "/api/articles/",
+ "/api/drafts/",
"/api/author-style/",
"/api/brief-sessions/",
+ })
+}
+
+func TestModelSelectorConfigContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for selector, label := range map[string]string{
+ "#style-model": "文体分析モデル",
+ "#brief-model": "深掘り質問モデル",
+ "#draft-model": "下書き生成モデル",
+ "#verify-model": "最終検証モデル",
+ } {
+ assertSelectorCount(t, contract.document, selector, 1)
+ if got := strings.TrimSpace(contract.document.Find(`label[for="` + strings.TrimPrefix(selector, "#") + `"]`).Text()); got != label {
+ t.Fatalf("label for %s = %q, want %q", selector, got, label)
+ }
+ }
+
+ assertScriptContains(t, contract.script, []string{
+ "el.styleModel.addEventListener('change', saveModelConfig)",
+ "el.briefModel.addEventListener('change', saveModelConfig)",
+ "el.draftModel.addEventListener('change', saveModelConfig)",
+ "el.verifyModel.addEventListener('change', saveModelConfig)",
+ })
+ assertFunctionContains(t, contract.script, "populateModelSelects", []string{
+ "const available = models.length ? models : ['gemma4:31b']",
+ "style: config.models.style || 'gemma4:latest'",
+ "brief: config.models.brief || 'gemma4:e2b'",
+ "draft: config.models.draft || 'gemma4:31b'",
+ "verify: config.models.verify || 'gemma4:latest'",
+ "setOptions(el.styleModel, available, defaults.style)",
+ "setOptions(el.briefModel, available, defaults.brief)",
+ "setOptions(el.draftModel, available, defaults.draft)",
+ "setOptions(el.verifyModel, available, defaults.verify)",
+ "saveModelConfig()",
+ })
+ assertFunctionContains(t, contract.script, "saveModelConfig", []string{
+ "config.models = {",
+ "style: el.styleModel.value",
+ "brief: el.briefModel.value",
+ "draft: el.draftModel.value",
+ "verify: el.verifyModel.value",
+ "saveConfig()",
+ })
+ assertFunctionContains(t, contract.script, "loadConfig", []string{
+ "models: { style: 'gemma4:e2b', brief: 'qwen3.6:27b', draft: 'gemma4:31b', verify: 'gemma4:latest' }",
+ "localStorage.getItem(configStorageKey)",
+ "models: { ...fallback.models, ...(saved.models || {}) }",
+ })
+}
+
+func TestQuestionCRUDPersistenceContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for _, selector := range []string{
+ "#question-config-list",
+ "#add-question-btn",
+ "#reset-questions-btn",
+ } {
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+ if got := strings.TrimSpace(contract.document.Find("#add-question-btn").Text()); got != "質問を追加" {
+ t.Fatalf("#add-question-btn text = %q, want 質問を追加", got)
+ }
+ if got := strings.TrimSpace(contract.document.Find("#reset-questions-btn").Text()); got != "初期値に戻す" {
+ t.Fatalf("#reset-questions-btn text = %q, want 初期値に戻す", got)
+ }
+
+ assertScriptContains(t, contract.script, []string{
+ "el.addQuestion.addEventListener('click', addQuestion)",
+ "el.resetQuestions.addEventListener('click', resetQuestions)",
+ "const configStorageKey = 'note-maker-config-v1'",
+ })
+ assertFunctionContains(t, contract.script, "createCustomQuestionRow", []string{
+ "input.setAttribute('aria-label', '追加質問')",
+ "input.addEventListener('input'",
+ "config.customQuestions[index].text = input.value",
+ "saveConfig()",
+ "remove.textContent = '削除'",
+ "config.customQuestions.splice(index, 1)",
+ "renderQuestionConfig()",
+ })
+ assertFunctionContains(t, contract.script, "addQuestion", []string{
+ "config.customQuestions.push",
+ "id: `custom_${Date.now()}`",
+ "text: '追加で聞きたい質問を入力してください'",
+ "target_field: 'custom'",
+ "saveConfig()",
+ "renderQuestionConfig()",
+ })
+ assertFunctionContains(t, contract.script, "resetQuestions", []string{
+ "config.customQuestions = []",
+ "saveConfig()",
+ "loadQuestionTemplate()",
+ })
+ assertFunctionContains(t, contract.script, "loadConfig", []string{
+ "saved.customQuestions || saved.custom_questions || migrateLegacyQuestions(saved.questions)",
+ "customQuestions: normalizeQuestionList(savedCustomQuestions)",
+ })
+ assertFunctionContains(t, contract.script, "currentQuestions", []string{
+ "const templateIds = new Set",
+ "const templateTexts = new Set",
+ "normalizeQuestionList(config.customQuestions)",
+ "!templateIds.has(question.id)",
+ "!templateTexts.has(question.text)",
+ })
+}
+
+func TestHistoryOpenControlsApplyReadableCardsContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertScriptContains(t, contract.script, []string{
+ "el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory)",
+ "el.historyStyleSelect.addEventListener('change', selectHistoryStyle)",
+ "el.historySessionSelect.addEventListener('change', selectHistorySession)",
+ "el.refreshHistory.addEventListener('click', loadWorkflowHistory)",
+ "el.openHistory.addEventListener('click', openSelectedHistory)",
+ "el.clearHistorySelection.addEventListener('click', clearHistorySelection)",
+ })
+ assertFunctionContains(t, contract.script, "loadWorkflowHistory", []string{
+ "state.historyLoading = true",
+ "state.selectedHistoryStyle = null",
+ "state.selectedHistorySession = null",
+ "fetchWorkflowHistoryIndex({ personaId, formatId })",
+ "normalizeWorkflowHistory(data, personaId, formatId)",
+ "renderHistoryPicker()",
+ })
+ assertFunctionContains(t, contract.script, "renderHistoryPicker", []string{
+ "renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択')",
+ "renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択')",
+ "el.openHistory.disabled = state.historyLoading || !historySelectionReady()",
+ "この書き手と出力先の保存済み履歴はまだありません。",
+ "選択できます。",
+ })
+ assertFunctionContains(t, contract.script, "openSelectedHistory", []string{
+ "loadHistoryStyleDetail",
+ "loadHistorySessionDetail",
+ "applyHistoryStyle(styleForSession)",
+ "await applyHistorySession(session)",
+ "選択した履歴を現在の作業状態に反映しました。",
+ })
+ assertFunctionContains(t, contract.script, "applyHistorySession", []string{
+ "config.mode.persona = data.personaId",
+ "config.mode.format = data.outputFormatId",
+ "await loadQuestionTemplate()",
+ "state.answers = data.answers || []",
+ "rememberQuestions(data.questions || state.templateQuestions)",
+ "renderTranscript",
+ "renderBriefCard(data.brief)",
+ "el.generateDraft.disabled = !state.profileId",
+ })
+}
+
+func TestHistoryProjectArticleDraftAdapterContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertFunctionContains(t, contract.script, "normalizeWorkflowHistory", []string{
+ "const projectValues = arrayFrom(source.projects || source.Projects)",
+ "...arrayFrom(source.articles || source.Articles)",
+ "...arrayFrom(source.article_history || source.articleHistory)",
+ "...arrayFrom(source.drafts || source.Drafts)",
+ "...arrayFrom(source.draft_versions || source.draftVersions)",
+ "projectValues.flatMap",
+ "project.articles || project.Articles",
+ "article.drafts || article.Drafts || article.draft_versions || article.draftVersions",
+ "article.current_draft || article.currentDraft || article.draft || article.Draft",
+ "projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject)",
+ "articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle)",
+ "drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft)",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryProject", []string{
+ "const project = item.project || item.Project || {}",
+ "project.id || project.ID",
+ "project.title || project.Title || project.name || project.Name",
+ "project.persona_id || project.PersonaID",
+ "project.output_format_id || project.OutputFormatID",
+ "item.articles || item.Articles || project.articles || project.Articles",
+ "normalizeHistoryArticle({ project_id: id, ...article })",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryArticle", []string{
+ "const article = item.article || item.Article || {}",
+ "article.id || article.ID",
+ "article.title || article.Title",
+ "item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief",
+ "item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft",
+ "item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts",
+ "article.source_snapshot || article.SourceSnapshot",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryDraft", []string{
+ "const draft = item.draft || item.Draft || {}",
+ "draft.id || draft.ID",
+ "draft.article_id || draft.ArticleID",
+ "draft.session_id || draft.SessionID",
+ "draft.style_profile_id || draft.StyleProfileID",
+ "draft.markdown || draft.Markdown || draft.text || draft.Text",
+ "draft.score ?? draft.Score",
+ })
+}
+
+func TestHistoryWrappedDetailResponseContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertFunctionContains(t, contract.script, "loadHistoryProjectDetail", []string{
+ "`/api/projects/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/projects/${encodeURIComponent(normalized.id)}`",
+ "return normalizeHistoryProject({ ...item, ...data })",
+ })
+ assertFunctionContains(t, contract.script, "loadHistoryArticleDetail", []string{
+ "`/api/articles/${encodeURIComponent(normalized.id)}`",
+ "`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/articles/${encodeURIComponent(normalized.id)}`",
+ "const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme)",
+ "return normalizeHistoryArticle({ ...item, ...detail })",
+ })
+ assertFunctionContains(t, contract.script, "loadHistoryDraftDetail", []string{
+ "`/api/drafts/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/drafts/${encodeURIComponent(normalized.id)}`",
+ "return normalizeHistoryDraft({ ...item, ...data })",
+ })
+ assertFunctionContains(t, contract.script, "requestFirstJSON", []string{
+ "for (const url of urls.filter(Boolean))",
+ "return await requestJSON(url)",
+ "throw lastError || new Error('履歴詳細APIが見つかりません')",
+ })
+}
+
+func TestArtifactCardsReadableContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for _, selector := range []string{
+ "#style-result #style-guide-card",
+ "#style-result .artifact-raw #guide-preview",
+ "#brief-result #brief-card",
+ "#brief-result .artifact-raw #brief-preview",
} {
- if !strings.Contains(source, want) {
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+
+ assertFunctionContains(t, contract.script, "renderStyleGuideCard", []string{
+ "el.styleGuideCard.className = 'artifact-card empty'",
+ "文体ガイドはまだありません。",
+ "createArtifactHeader",
+ "['Profile', normalized.profileId || normalized.id]",
+ "['Guide', normalized.guideId]",
+ "['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)]",
+ "markdownSectionsForCard(markdown)",
+ "createArtifactSection",
+ })
+ assertFunctionContains(t, contract.script, "renderBriefCard", []string{
+ "el.briefCard.className = 'artifact-card empty'",
+ "記事ブリーフはまだありません。",
+ "createArtifactHeader",
+ "['Persona', briefField(brief, 'persona_id', 'PersonaID')]",
+ "['Format', briefField(brief, 'output_format_id', 'OutputFormatID')]",
+ "['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')]",
+ "['読者', briefField(brief, 'reader', 'Reader')]",
+ "['必ず含めること', briefField(brief, 'must_include', 'MustInclude')]",
+ "createAnswerSection('追加回答', customAnswers)",
+ "createAnswerSection('深掘りメモ', deepDives)",
+ })
+ assertFunctionContains(t, contract.script, "createArtifactHeader", []string{
+ "header.className = 'artifact-card-header'",
+ "titleElement.textContent = title",
+ "meta.className = 'artifact-meta'",
+ })
+ assertFunctionContains(t, contract.script, "createAnswerSection", []string{
+ "state.questionTextById[questionId]",
+ "return `${question}: ${content}`",
+ })
+}
+
+func loadStaticContract(t *testing.T) staticContract {
+ t.Helper()
+
+ index, err := os.ReadFile("index.html")
+ if err != nil {
+ t.Fatalf("read index: %v", err)
+ }
+ script, err := os.ReadFile("js/script.js")
+ if err != nil {
+ t.Fatalf("read script: %v", err)
+ }
+ document, err := goquery.NewDocumentFromReader(bytes.NewReader(index))
+ if err != nil {
+ t.Fatalf("parse index: %v", err)
+ }
+ return staticContract{document: document, script: string(script)}
+}
+
+func assertSelectorCount(t *testing.T, document *goquery.Document, selector string, want int) {
+ t.Helper()
+ if got := document.Find(selector).Length(); got != want {
+ t.Fatalf("selector %s count = %d, want %d", selector, got, want)
+ }
+}
+
+func assertScriptContains(t *testing.T, script string, wants []string) {
+ t.Helper()
+ for _, want := range wants {
+ if !strings.Contains(script, want) {
t.Fatalf("script missing %q", want)
}
}
}
+
+func assertFunctionContains(t *testing.T, script, name string, wants []string) {
+ t.Helper()
+ body, ok := functionBody(script, name)
+ if !ok {
+ t.Fatalf("script missing function %s", name)
+ }
+ for _, want := range wants {
+ if !strings.Contains(body, want) {
+ t.Fatalf("function %s missing %q", name, want)
+ }
+ }
+}
+
+func functionBody(script, name string) (string, bool) {
+ signature := "function " + name + "("
+ start := strings.Index(script, signature)
+ if start < 0 {
+ return "", false
+ }
+ open := functionBodyOpen(script, start+len("function "+name))
+ if open < 0 {
+ return "", false
+ }
+ depth := 0
+ for index := open; index < len(script); index++ {
+ switch script[index] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return script[open : index+1], true
+ }
+ }
+ }
+ return "", false
+}
+
+func functionBodyOpen(script string, parenStart int) int {
+ if parenStart >= len(script) || script[parenStart] != '(' {
+ return -1
+ }
+ depth := 0
+ for index := parenStart; index < len(script); index++ {
+ switch script[index] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ for next := index + 1; next < len(script); next++ {
+ if script[next] == '{' {
+ return next
+ }
+ if script[next] != ' ' && script[next] != '\n' && script[next] != '\t' && script[next] != '\r' {
+ return -1
+ }
+ }
+ return -1
+ }
+ }
+ }
+ return -1
+}
diff --git a/static/index.html b/static/index.html
index 882d4e7..7ab96e6 100644
--- a/static/index.html
+++ b/static/index.html
@@ -87,6 +87,18 @@
履歴から再開
履歴の書き手
+
+ プロジェクト
+
+
+
+ 記事
+
+
+
+ 下書き
+
+
保存済み文体ガイド
@@ -101,6 +113,14 @@
履歴から再開
選択を解除
+
diff --git a/static/js/script.js b/static/js/script.js
index 427227e..3f9e068 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -51,11 +51,17 @@ document.addEventListener('DOMContentLoaded', () => {
templateRequestId: 0,
historyStyles: [],
historySessions: [],
+ historyProjects: [],
+ historyArticles: [],
+ historyDrafts: [],
historyLoading: false,
historyError: '',
historyRequestId: 0,
selectedHistoryStyle: null,
selectedHistorySession: null,
+ selectedHistoryProject: null,
+ selectedHistoryArticle: null,
+ selectedHistoryDraft: null,
storageConfig: null,
questionTextById: {},
lastSubmittedAnswer: '',
@@ -78,12 +84,22 @@ document.addEventListener('DOMContentLoaded', () => {
saveStorage: document.getElementById('save-storage-btn'),
storageSummary: document.getElementById('storage-summary'),
historyPersonaSelect: document.getElementById('history-persona-select'),
+ historyProjectSelect: document.getElementById('history-project-select'),
+ historyArticleSelect: document.getElementById('history-article-select'),
+ historyDraftSelect: document.getElementById('history-draft-select'),
historyStyleSelect: document.getElementById('history-style-select'),
historySessionSelect: document.getElementById('history-session-select'),
refreshHistory: document.getElementById('refresh-history-btn'),
openHistory: document.getElementById('open-history-btn'),
clearHistorySelection: document.getElementById('clear-history-selection-btn'),
historyStatus: document.getElementById('history-status'),
+ historyArticleDetail: document.getElementById('history-article-detail'),
+ historyProjectCard: document.getElementById('history-project-card'),
+ historyArticleCard: document.getElementById('history-article-card'),
+ historyArticleBriefCard: document.getElementById('history-article-brief-card'),
+ historyCurrentDraftCard: document.getElementById('history-current-draft-card'),
+ historyDraftVersionsCard: document.getElementById('history-draft-versions-card'),
+ historySourceSnapshotCard: document.getElementById('history-source-snapshot-card'),
questionConfigList: document.getElementById('question-config-list'),
addQuestion: document.getElementById('add-question-btn'),
resetQuestions: document.getElementById('reset-questions-btn'),
@@ -129,6 +145,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
renderQuestionConfig();
+ renderHistoryArticleDetail();
initializeModeControls();
checkModels();
loadStorageConfig();
@@ -142,6 +159,9 @@ document.addEventListener('DOMContentLoaded', () => {
el.storageDriver.addEventListener('change', onStorageDriverChange);
el.saveStorage.addEventListener('click', saveStorageConfig);
el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory);
+ el.historyProjectSelect.addEventListener('change', selectHistoryProject);
+ el.historyArticleSelect.addEventListener('change', selectHistoryArticle);
+ el.historyDraftSelect.addEventListener('change', selectHistoryDraft);
el.historyStyleSelect.addEventListener('change', selectHistoryStyle);
el.historySessionSelect.addEventListener('change', selectHistorySession);
el.refreshHistory.addEventListener('click', loadWorkflowHistory);
@@ -818,7 +838,11 @@ document.addEventListener('DOMContentLoaded', () => {
state.historyError = '';
state.selectedHistoryStyle = null;
state.selectedHistorySession = null;
+ state.selectedHistoryProject = null;
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
renderHistoryPicker();
+ renderHistoryArticleDetail();
try {
const data = await fetchWorkflowHistoryIndex({ personaId, formatId });
@@ -828,17 +852,24 @@ document.addEventListener('DOMContentLoaded', () => {
const normalized = normalizeWorkflowHistory(data, personaId, formatId);
state.historyStyles = normalized.styles;
state.historySessions = normalized.sessions;
+ state.historyProjects = normalized.projects;
+ state.historyArticles = normalized.articles;
+ state.historyDrafts = normalized.drafts;
} catch (error) {
if (requestId !== state.historyRequestId) {
return;
}
state.historyStyles = [];
state.historySessions = [];
+ state.historyProjects = [];
+ state.historyArticles = [];
+ state.historyDrafts = [];
state.historyError = historyErrorMessage(error);
} finally {
if (requestId === state.historyRequestId) {
state.historyLoading = false;
renderHistoryPicker();
+ renderHistoryArticleDetail();
}
}
}
@@ -855,16 +886,24 @@ document.addEventListener('DOMContentLoaded', () => {
}
function renderHistoryPicker() {
+ const articles = filteredHistoryArticles();
+ const drafts = filteredHistoryDrafts();
+ renderHistoryOptions(el.historyProjectSelect, state.historyProjects, 'プロジェクトを選択');
+ renderHistoryOptions(el.historyArticleSelect, articles, '記事を選択');
+ renderHistoryOptions(el.historyDraftSelect, drafts, '下書きを選択');
renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択');
renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択');
+ el.historyProjectSelect.disabled = state.historyLoading || !state.historyProjects.length;
+ el.historyArticleSelect.disabled = state.historyLoading || !articles.length;
+ el.historyDraftSelect.disabled = state.historyLoading || !drafts.length;
el.historyStyleSelect.disabled = state.historyLoading || !state.historyStyles.length;
el.historySessionSelect.disabled = state.historyLoading || !state.historySessions.length;
el.openHistory.disabled = state.historyLoading || !historySelectionReady();
if (state.historyLoading) {
el.historyStatus.className = 'history-status loading';
- el.historyStatus.textContent = '保存済みの文体ガイドと取材セッションを読み込んでいます...';
+ el.historyStatus.textContent = '保存済みのプロジェクト、記事、下書き、文体ガイド、取材セッションを読み込んでいます...';
return;
}
if (state.historyError) {
@@ -872,15 +911,18 @@ document.addEventListener('DOMContentLoaded', () => {
el.historyStatus.textContent = state.historyError;
return;
}
- if (!state.historyStyles.length && !state.historySessions.length) {
+ if (!state.historyProjects.length && !state.historyArticles.length && !state.historyDrafts.length && !state.historyStyles.length && !state.historySessions.length) {
el.historyStatus.className = 'history-status empty';
el.historyStatus.textContent = 'この書き手と出力先の保存済み履歴はまだありません。';
return;
}
+ const projectCount = `${state.historyProjects.length}件のプロジェクト`;
+ const articleCount = `${state.historyArticles.length}件の記事`;
+ const draftCount = `${state.historyDrafts.length}件の下書き`;
const styleCount = `${state.historyStyles.length}件の文体ガイド`;
const sessionCount = `${state.historySessions.length}件の取材セッション`;
el.historyStatus.className = 'history-status';
- el.historyStatus.textContent = `${styleCount} / ${sessionCount} を選択できます。`;
+ el.historyStatus.textContent = `${projectCount} / ${articleCount} / ${draftCount} / ${styleCount} / ${sessionCount} を選択できます。`;
}
function renderHistoryOptions(select, items, placeholder) {
@@ -909,7 +951,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
function historySelectionReady() {
- return Boolean(el.historyStyleSelect.value || el.historySessionSelect.value);
+ return Boolean(
+ el.historyProjectSelect.value
+ || el.historyArticleSelect.value
+ || el.historyDraftSelect.value
+ || el.historyStyleSelect.value
+ || el.historySessionSelect.value,
+ );
}
function historyErrorMessage(error) {
@@ -964,6 +1012,84 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
+ async function selectHistoryProject() {
+ state.selectedHistoryProject = findHistoryProject(el.historyProjectSelect.value);
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
+ el.historyArticleSelect.value = '';
+ el.historyDraftSelect.value = '';
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ if (!state.selectedHistoryProject) {
+ return;
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = 'プロジェクト履歴を確認しています...';
+ try {
+ state.selectedHistoryProject = await loadHistoryProjectDetail(state.selectedHistoryProject);
+ mergeProjectDetailIntoHistory(state.selectedHistoryProject);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `プロジェクト詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
+ async function selectHistoryArticle() {
+ state.selectedHistoryArticle = findHistoryArticle(el.historyArticleSelect.value);
+ state.selectedHistoryDraft = null;
+ el.historyDraftSelect.value = '';
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ if (!state.selectedHistoryArticle) {
+ return;
+ }
+ if (state.selectedHistoryArticle.projectId && !state.selectedHistoryProject) {
+ state.selectedHistoryProject = findHistoryProject(state.selectedHistoryArticle.projectId);
+ if (state.selectedHistoryProject) {
+ el.historyProjectSelect.value = state.selectedHistoryProject.id;
+ }
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '記事履歴を確認しています...';
+ try {
+ state.selectedHistoryArticle = await loadHistoryArticleDetail(state.selectedHistoryArticle);
+ mergeArticleDetailIntoHistory(state.selectedHistoryArticle);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `記事詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
+ async function selectHistoryDraft() {
+ state.selectedHistoryDraft = findHistoryDraft(el.historyDraftSelect.value);
+ if (!state.selectedHistoryDraft) {
+ renderHistoryArticleDetail();
+ el.openHistory.disabled = !historySelectionReady();
+ return;
+ }
+ if (state.selectedHistoryDraft.articleId && !state.selectedHistoryArticle) {
+ state.selectedHistoryArticle = findHistoryArticle(state.selectedHistoryDraft.articleId);
+ if (state.selectedHistoryArticle) {
+ el.historyArticleSelect.value = state.selectedHistoryArticle.id;
+ }
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '下書き履歴を確認しています...';
+ try {
+ state.selectedHistoryDraft = await loadHistoryDraftDetail(state.selectedHistoryDraft);
+ mergeDraftDetailIntoHistory(state.selectedHistoryDraft);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `下書き詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
async function openSelectedHistory() {
clearError();
el.historyStatus.className = 'history-status loading';
@@ -975,6 +1101,12 @@ document.addEventListener('DOMContentLoaded', () => {
const session = el.historySessionSelect.value
? await loadHistorySessionDetail(state.selectedHistorySession || findHistorySession(el.historySessionSelect.value))
: null;
+ const article = el.historyArticleSelect.value
+ ? await loadHistoryArticleDetail(state.selectedHistoryArticle || findHistoryArticle(el.historyArticleSelect.value))
+ : null;
+ const draft = el.historyDraftSelect.value
+ ? await loadHistoryDraftDetail(state.selectedHistoryDraft || findHistoryDraft(el.historyDraftSelect.value))
+ : null;
const styleForSession = !style && session?.styleProfileId
? await loadHistoryStyleDetail({ id: session.styleProfileId })
: style;
@@ -985,7 +1117,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (session) {
await applyHistorySession(session);
}
- if (!styleForSession && !session) {
+ if (article) {
+ await applyHistoryArticle(article);
+ }
+ if (draft) {
+ applyHistoryDraft(draft);
+ }
+ if (!styleForSession && !session && !article && !draft && !el.historyProjectSelect.value) {
showError('開く履歴を選択してください');
return;
}
@@ -998,11 +1136,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
function clearHistorySelection() {
+ el.historyProjectSelect.value = '';
+ el.historyArticleSelect.value = '';
+ el.historyDraftSelect.value = '';
el.historyStyleSelect.value = '';
el.historySessionSelect.value = '';
+ state.selectedHistoryProject = null;
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
state.selectedHistoryStyle = null;
state.selectedHistorySession = null;
renderHistoryPicker();
+ renderHistoryArticleDetail();
}
async function loadHistoryStyleDetail(item) {
@@ -1027,6 +1172,81 @@ document.addEventListener('DOMContentLoaded', () => {
return normalizeHistorySession({ ...item, ...data });
}
+ async function loadHistoryProjectDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryProject(item);
+ if (normalized.articles.length) {
+ return normalized;
+ }
+ const data = await requestFirstJSON([
+ `/api/projects/${encodeURIComponent(normalized.id)}`,
+ `/api/history/projects/${encodeURIComponent(normalized.id)}`,
+ ]);
+ return normalizeHistoryProject({ ...item, ...data });
+ }
+
+ async function loadHistoryArticleDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryArticle(item);
+ if (hasArticleDetail(normalized)) {
+ return normalized;
+ }
+ const urls = [`/api/articles/${encodeURIComponent(normalized.id)}`];
+ if (normalized.projectId) {
+ urls.push(`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`);
+ }
+ if (normalized.briefId) {
+ urls.push(`/api/briefs/${encodeURIComponent(normalized.briefId)}`);
+ }
+ urls.push(`/api/history/articles/${encodeURIComponent(normalized.id)}`);
+ const data = await requestFirstJSON(urls);
+ const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme)
+ ? { brief: data }
+ : data;
+ return normalizeHistoryArticle({ ...item, ...detail });
+ }
+
+ async function loadHistoryDraftDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryDraft(item);
+ if (normalized.markdown || normalized.summary) {
+ return normalized;
+ }
+ const data = await requestFirstJSON([
+ `/api/drafts/${encodeURIComponent(normalized.id)}`,
+ `/api/history/drafts/${encodeURIComponent(normalized.id)}`,
+ ]);
+ return normalizeHistoryDraft({ ...item, ...data });
+ }
+
+ async function requestFirstJSON(urls) {
+ let lastError = null;
+ for (const url of urls.filter(Boolean)) {
+ try {
+ return await requestJSON(url);
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ throw lastError || new Error('履歴詳細APIが見つかりません');
+ }
+
+ function hasArticleDetail(article) {
+ return Boolean(
+ article.brief
+ || article.currentDraft?.markdown
+ || article.currentDraft?.summary
+ || article.draftVersions.length
+ || article.sourceSnapshot,
+ );
+ }
+
function applyHistoryStyle(item) {
const data = normalizeHistoryStyle(item);
state.profileId = data.profileId || data.id;
@@ -1082,12 +1302,97 @@ document.addEventListener('DOMContentLoaded', () => {
updateSectionControls();
}
+ async function applyHistoryArticle(item) {
+ const data = normalizeHistoryArticle(item);
+ state.selectedHistoryArticle = data;
+ state.profileId = data.styleProfileId || state.profileId;
+ state.sessionId = data.sessionId || state.sessionId;
+ if (data.personaId && state.personas.some((persona) => persona.id === data.personaId)) {
+ el.personaSelect.value = data.personaId;
+ config.mode.persona = data.personaId;
+ }
+ if (data.outputFormatId && state.formats.some((format) => format.id === data.outputFormatId)) {
+ el.formatSelect.value = data.outputFormatId;
+ config.mode.format = data.outputFormatId;
+ }
+ saveConfig();
+ renderModeSummary();
+ await loadQuestionTemplate();
+ if (data.brief) {
+ state.completedBrief = data.brief;
+ renderBriefCard(data.brief);
+ el.briefPreview.textContent = JSON.stringify(data.brief, null, 2);
+ el.briefResult.classList.remove('hidden');
+ el.generateDraft.disabled = !state.profileId;
+ }
+ const draft = state.selectedHistoryDraft || data.currentDraft;
+ if (draft) {
+ applyHistoryDraft(draft);
+ }
+ renderHistoryArticleDetail();
+ }
+
+ function applyHistoryDraft(item) {
+ const data = normalizeHistoryDraft(item);
+ state.selectedHistoryDraft = data;
+ state.sessionId = data.sessionId || state.sessionId;
+ state.profileId = data.styleProfileId || state.profileId;
+ const draftText = data.markdown || data.summary || '';
+ if (draftText) {
+ el.markdownOutput.value = draftText;
+ syncDraftEditor();
+ el.draftResult.classList.remove('hidden');
+ setActiveTab('preview');
+ }
+ el.draftStatus.textContent = historyDraftResumeText(data);
+ el.generateDraft.disabled = !state.profileId || !state.sessionId;
+ renderHistoryArticleDetail();
+ }
+
function normalizeWorkflowHistory(data, personaId, formatId) {
const source = data || {};
const styleValues = arrayFrom(source.style_guides || source.styleGuides || source.styles || source.author_styles || source.authorStyles || source.profiles);
+ const briefValues = arrayFrom(source.briefs || source.Briefs);
const sessionValues = [
...arrayFrom(source.sessions || source.brief_sessions || source.briefSessions || source.items || (Array.isArray(source) ? source : [])),
- ...arrayFrom(source.briefs || source.Briefs),
+ ...briefValues,
+ ];
+ const projectValues = arrayFrom(source.projects || source.Projects);
+ const projectArticleValues = projectValues.flatMap((project) => arrayFrom(project.articles || project.Articles)
+ .map((article) => ({ project_id: project.id || project.ID || project.project_id || project.projectId, ...article })));
+ const sourceSnapshotValues = arrayFrom(source.source_snapshots || source.sourceSnapshots || source.SourceSnapshots);
+ const briefArticleValues = briefValues.map((brief) => ({
+ article_id: brief.article_id || brief.articleId || brief.session_id || brief.sessionId || brief.id || brief.ID,
+ persona_id: brief.persona_id || brief.personaId,
+ output_format_id: brief.output_format_id || brief.outputFormatId,
+ brief,
+ }));
+ const snapshotArticleValues = sourceSnapshotValues.map((snapshot) => ({
+ article_id: snapshot.article_id || snapshot.articleId || snapshot.id || snapshot.ID,
+ persona_id: snapshot.persona_id || snapshot.personaId,
+ output_format_id: snapshot.output_format_id || snapshot.outputFormatId,
+ source_snapshot: snapshot,
+ }));
+ const articleValues = [
+ ...arrayFrom(source.articles || source.Articles),
+ ...arrayFrom(source.article_history || source.articleHistory),
+ ...projectArticleValues,
+ ...briefArticleValues,
+ ...snapshotArticleValues,
+ ];
+ const articleDraftValues = articleValues.flatMap((article) => [
+ ...arrayFrom(article.drafts || article.Drafts || article.draft_versions || article.draftVersions),
+ ...[article.current_draft || article.currentDraft || article.draft || article.Draft].filter(Boolean),
+ ].map((draft) => ({
+ article_id: article.id || article.ID || article.article_id || article.articleId,
+ persona_id: article.persona_id || article.personaId,
+ output_format_id: article.output_format_id || article.outputFormatId,
+ ...draft,
+ })));
+ const draftValues = [
+ ...arrayFrom(source.drafts || source.Drafts),
+ ...arrayFrom(source.draft_versions || source.draftVersions),
+ ...articleDraftValues,
];
return {
styles: styleValues.map(normalizeHistoryStyle)
@@ -1096,6 +1401,15 @@ document.addEventListener('DOMContentLoaded', () => {
sessions: uniqueHistoryItems(sessionValues.map(normalizeHistorySession)
.filter((item) => item.id)
.filter((item) => historyItemMatches(item, personaId, formatId))),
+ projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
+ articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
+ drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
};
}
@@ -1144,6 +1458,89 @@ document.addEventListener('DOMContentLoaded', () => {
};
}
+ function normalizeHistoryProject(item = {}) {
+ const project = item.project || item.Project || {};
+ const id = String(item.project_id || item.projectId || project.id || project.ID || item.id || item.ID || '').trim();
+ return {
+ ...item,
+ id,
+ title: item.title || item.name || item.label || item.display_name || item.displayName || project.title || project.Title || project.name || project.Name || id,
+ personaId: item.persona_id || item.personaId || project.persona_id || project.PersonaID || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || project.output_format_id || project.OutputFormatID || '',
+ status: item.status || item.Status || project.status || project.Status || '',
+ articleCount: item.article_count ?? item.articleCount ?? project.article_count ?? project.ArticleCount,
+ articles: arrayFrom(item.articles || item.Articles || project.articles || project.Articles).map((article) => normalizeHistoryArticle({ project_id: id, ...article })),
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || project.updated_at || project.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || project.created_at || project.CreatedAt || '',
+ };
+ }
+
+ function normalizeHistoryArticle(item = {}) {
+ const article = item.article || item.Article || {};
+ const brief = item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief || null;
+ const currentDraft = normalizeMaybeDraft(item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft || null);
+ const draftVersions = arrayFrom(item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts)
+ .map((draft) => normalizeHistoryDraft({ article_id: item.article_id || item.articleId || article.id || article.ID || item.id || item.ID, ...draft }))
+ .filter((draft) => draft.id || draft.markdown || draft.summary);
+ const id = String(item.article_id || item.articleId || article.id || article.ID || item.id || item.ID || '').trim();
+ const title = item.title || item.name || article.title || article.Title || briefField(brief, 'theme', 'Theme') || currentDraft?.title || id;
+ return {
+ ...item,
+ id,
+ projectId: item.project_id || item.projectId || article.project_id || article.ProjectID || '',
+ title,
+ theme: briefField(brief, 'theme', 'Theme'),
+ personaId: item.persona_id || item.personaId || article.persona_id || article.PersonaID || briefField(brief, 'persona_id', 'PersonaID') || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || article.output_format_id || article.OutputFormatID || briefField(brief, 'output_format_id', 'OutputFormatID') || '',
+ styleProfileId: item.style_profile_id || item.styleProfileId || article.style_profile_id || article.StyleProfileID || briefField(brief, 'style_profile_id', 'StyleProfileID') || '',
+ sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || article.session_id || article.SessionID || '',
+ briefId: item.brief_id || item.briefId || article.brief_id || article.BriefID || '',
+ status: item.status || item.Status || item.phase || item.Phase || article.status || article.Status || '',
+ brief,
+ currentDraft,
+ draftVersions,
+ sourceSnapshot: item.source_snapshot || item.sourceSnapshot || article.source_snapshot || article.SourceSnapshot || null,
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || article.updated_at || article.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || article.created_at || article.CreatedAt || '',
+ };
+ }
+
+ function normalizeMaybeDraft(draft) {
+ if (!draft) {
+ return null;
+ }
+ const normalized = normalizeHistoryDraft(draft);
+ return normalized.id || normalized.markdown || normalized.summary ? normalized : null;
+ }
+
+ function normalizeHistoryDraft(item = {}) {
+ const draft = item.draft || item.Draft || {};
+ const markdown = item.markdown || item.Markdown || item.draft_markdown || item.draftMarkdown || item.text || item.Text || item.body || item.Body || item.content || item.Content || draft.markdown || draft.Markdown || draft.text || draft.Text || '';
+ const id = String(item.draft_id || item.draftId || draft.id || draft.ID || item.id || item.ID || '').trim();
+ return {
+ ...item,
+ id,
+ articleId: item.article_id || item.articleId || draft.article_id || draft.ArticleID || '',
+ personaId: item.persona_id || item.personaId || draft.persona_id || draft.PersonaID || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || draft.output_format_id || draft.OutputFormatID || '',
+ sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || draft.session_id || draft.SessionID || '',
+ styleProfileId: item.style_profile_id || item.styleProfileId || draft.style_profile_id || draft.StyleProfileID || '',
+ title: item.title || item.name || draft.title || draft.Title || markdownTitle(markdown) || id,
+ version: item.version ?? item.Version ?? item.attempt ?? item.Attempt ?? item.revision ?? item.Revision ?? '',
+ kind: item.kind || item.Kind || '',
+ status: item.status || item.Status || '',
+ markdown,
+ summary: item.summary || item.Summary || draft.summary || draft.Summary || '',
+ score: item.score ?? item.Score ?? item.quality_gate?.score ?? item.qualityGate?.score ?? draft.score ?? draft.Score,
+ passed: item.passed ?? item.Passed ?? item.evaluation?.passed ?? item.Evaluation?.Passed,
+ runes: item.runes ?? item.Runes ?? item.quality_gate?.runes ?? item.qualityGate?.runes,
+ validationError: item.validation_error || item.validationError || item.ValidationError || '',
+ verification: item.verification || item.Verification || null,
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || draft.updated_at || draft.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || draft.created_at || draft.CreatedAt || '',
+ };
+ }
+
function normalizeHistoryQuestion(question) {
if (!question) {
return null;
@@ -1172,6 +1569,37 @@ document.addEventListener('DOMContentLoaded', () => {
return state.historySessions.find((item) => item.id === id) || null;
}
+ function findHistoryProject(id) {
+ return state.historyProjects.find((item) => item.id === id) || null;
+ }
+
+ function findHistoryArticle(id) {
+ return state.historyArticles.find((item) => item.id === id) || null;
+ }
+
+ function findHistoryDraft(id) {
+ return state.historyDrafts.find((item) => item.id === id) || null;
+ }
+
+ function filteredHistoryArticles() {
+ const projectId = el.historyProjectSelect.value;
+ if (!projectId) {
+ return state.historyArticles;
+ }
+ return state.historyArticles.filter((article) => !article.projectId || article.projectId === projectId);
+ }
+
+ function filteredHistoryDrafts() {
+ const articleId = el.historyArticleSelect.value;
+ if (!articleId) {
+ return state.historyDrafts;
+ }
+ const article = state.selectedHistoryArticle || findHistoryArticle(articleId);
+ const articleDrafts = article?.draftVersions || [];
+ const globalDrafts = state.historyDrafts.filter((draft) => !draft.articleId || draft.articleId === articleId);
+ return uniqueHistoryItems([...articleDrafts, ...globalDrafts]);
+ }
+
function uniqueHistoryItems(items) {
const byId = new Map();
items.forEach((item) => {
@@ -1183,6 +1611,248 @@ document.addEventListener('DOMContentLoaded', () => {
return [...byId.values()];
}
+ function mergeProjectDetailIntoHistory(project) {
+ if (!project) {
+ return;
+ }
+ state.historyProjects = uniqueHistoryItems([project, ...state.historyProjects]);
+ if (project.articles.length) {
+ state.historyArticles = uniqueHistoryItems([...project.articles, ...state.historyArticles]);
+ }
+ }
+
+ function mergeArticleDetailIntoHistory(article) {
+ if (!article) {
+ return;
+ }
+ state.historyArticles = uniqueHistoryItems([article, ...state.historyArticles]);
+ if (article.draftVersions.length) {
+ state.historyDrafts = uniqueHistoryItems([...article.draftVersions, ...state.historyDrafts]);
+ }
+ if (article.currentDraft) {
+ state.historyDrafts = uniqueHistoryItems([article.currentDraft, ...state.historyDrafts]);
+ }
+ }
+
+ function mergeDraftDetailIntoHistory(draft) {
+ if (!draft) {
+ return;
+ }
+ state.historyDrafts = uniqueHistoryItems([draft, ...state.historyDrafts]);
+ }
+
+ function renderHistoryArticleDetail(options = {}) {
+ renderHistoryProjectCard(state.selectedHistoryProject, options.warning);
+ renderHistoryArticleCard(state.selectedHistoryArticle, options.warning);
+ const article = state.selectedHistoryArticle;
+ const selectedDraft = state.selectedHistoryDraft;
+ renderHistoryBriefCard(article?.brief || null);
+ renderHistoryCurrentDraftCard(selectedDraft || article?.currentDraft || null);
+ renderHistoryDraftVersionsCard(article?.draftVersions || filteredHistoryDrafts());
+ renderHistorySourceSnapshotCard(article?.sourceSnapshot || null);
+ }
+
+ function renderHistoryProjectCard(project, warning) {
+ if (!project) {
+ fillArtifactCard(el.historyProjectCard, null, [], [], 'プロジェクトを選択すると、記事一覧と更新状況をここに表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyProjectCard,
+ project.title || 'プロジェクト',
+ [
+ ['Project', project.id],
+ ['Persona', project.personaId],
+ ['Format', project.outputFormatId],
+ ['Status', project.status],
+ ['Updated', formatDateTime(project.updatedAt)],
+ ],
+ [
+ ['記事', [`${project.articleCount ?? project.articles.length ?? 0}件`]],
+ warning ? ['注意', [warning]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryArticleCard(article, warning) {
+ if (!article) {
+ fillArtifactCard(el.historyArticleCard, null, [], warning ? [['注意', [warning]]] : [], '記事を選択すると、ブリーフ、下書き、参照ソースの要約をここに表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyArticleCard,
+ article.title || '記事',
+ [
+ ['Article', article.id],
+ ['Project', article.projectId],
+ ['Persona', article.personaId],
+ ['Format', article.outputFormatId],
+ ['Status', article.status],
+ ['Updated', formatDateTime(article.updatedAt)],
+ ],
+ [
+ ['ブリーフ', [article.brief ? briefField(article.brief, 'theme', 'Theme') || '保存済み' : '未接続または未作成']],
+ ['下書き', [`${article.draftVersions.length}件のバージョン${article.currentDraft ? ' / 現在版あり' : ''}`]],
+ warning ? ['注意', [warning]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryBriefCard(brief) {
+ if (!brief) {
+ fillArtifactCard(el.historyArticleBriefCard, null, [], [], '記事ブリーフはまだありません。詳細API接続後、テーマ、読者、含める内容を要約表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyArticleBriefCard,
+ briefField(brief, 'theme', 'Theme') || '記事ブリーフ',
+ [
+ ['Persona', briefField(brief, 'persona_id', 'PersonaID')],
+ ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')],
+ ['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')],
+ ],
+ [
+ ['読者', [briefField(brief, 'reader', 'Reader')]],
+ ['冒頭の具体例', [briefField(brief, 'opening_episode', 'OpeningEpisode')]],
+ ['必ず含めること', [briefField(brief, 'must_include', 'MustInclude')]],
+ ['読後アクション', [briefField(brief, 'expected_reader_action', 'ExpectedReaderAction')]],
+ ],
+ );
+ }
+
+ function renderHistoryCurrentDraftCard(draft) {
+ if (!draft) {
+ fillArtifactCard(el.historyCurrentDraftCard, null, [], [], '現在の下書きはまだありません。下書き詳細API接続後、本文の冒頭と評価を表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyCurrentDraftCard,
+ draft.title || '現在の下書き',
+ [
+ ['Draft', draft.id],
+ ['Version', draft.version],
+ ['Score', draft.score === undefined ? '' : Number(draft.score).toFixed(1)],
+ ['Status', draft.status || draft.kind],
+ ['Updated', formatDateTime(draft.updatedAt)],
+ ],
+ [
+ ['本文要約', summarizeDraftLines(draft, 5)],
+ draft.validationError ? ['検証メモ', [draft.validationError]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryDraftVersionsCard(drafts) {
+ const versions = arrayFrom(drafts).filter((draft) => draft.id || draft.markdown || draft.summary);
+ if (!versions.length) {
+ fillArtifactCard(el.historyDraftVersionsCard, null, [], [], '下書きバージョンはまだありません。保存済みバージョンがあると番号、評価、更新日時を一覧表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyDraftVersionsCard,
+ '下書きバージョン',
+ [['Versions', versions.length]],
+ [['一覧', versions.slice(0, 8).map(historyDraftVersionLine)]],
+ );
+ }
+
+ function renderHistorySourceSnapshotCard(snapshot) {
+ const normalized = normalizeSourceSnapshot(snapshot);
+ if (!normalized) {
+ fillArtifactCard(el.historySourceSnapshotCard, null, [], [], 'ソーススナップショットはまだありません。接続後、参照記事や取得日時を表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historySourceSnapshotCard,
+ normalized.title || 'ソーススナップショット',
+ [
+ ['Fetched', formatDateTime(normalized.fetchedAt)],
+ ['Articles', normalized.articles.length],
+ ],
+ [
+ ['参照ソース', normalized.articles.length ? normalized.articles.slice(0, 6).map(sourceArticleLine) : [normalized.summary]],
+ ],
+ );
+ }
+
+ function fillArtifactCard(card, title, metaItems, sections, emptyText) {
+ card.innerHTML = '';
+ if (!title && !arrayFrom(sections).length) {
+ card.className = 'artifact-card empty';
+ card.textContent = emptyText;
+ return;
+ }
+ card.className = 'artifact-card';
+ if (title) {
+ card.appendChild(createArtifactHeader(title, metaItems));
+ }
+ arrayFrom(sections).forEach(([sectionTitle, values]) => {
+ const visibleValues = arrayFrom(values).filter((value) => String(value || '').trim());
+ if (visibleValues.length) {
+ card.appendChild(createArtifactSection(sectionTitle, visibleValues));
+ }
+ });
+ }
+
+ function normalizeSourceSnapshot(snapshot) {
+ if (!snapshot) {
+ return null;
+ }
+ if (Array.isArray(snapshot)) {
+ return {
+ title: 'ソーススナップショット',
+ fetchedAt: '',
+ summary: '',
+ articles: snapshot,
+ };
+ }
+ if (typeof snapshot === 'string') {
+ return {
+ title: 'ソーススナップショット',
+ fetchedAt: '',
+ summary: snapshot,
+ articles: [],
+ };
+ }
+ const articles = arrayFrom(snapshot.articles || snapshot.Articles || snapshot.sources || snapshot.Sources);
+ return {
+ title: snapshot.title || snapshot.Title || snapshot.source_selector || snapshot.sourceSelector || '',
+ fetchedAt: snapshot.fetched_at || snapshot.fetchedAt || snapshot.created_at || snapshot.createdAt || '',
+ summary: snapshot.summary || snapshot.Summary || snapshot.url || snapshot.URL || '',
+ articles,
+ };
+ }
+
+ function sourceArticleLine(article) {
+ const title = article.title || article.Title || article.id || article.ID || '参照記事';
+ const url = article.url || article.URL || '';
+ const fetchedAt = formatDateTime(article.fetched_at || article.fetchedAt || article.at || article.At);
+ return [title, url, fetchedAt].filter(Boolean).join(' / ');
+ }
+
+ function summarizeDraftLines(draft, limit) {
+ return compactTextLines(draft.markdown || draft.summary || '', limit).map((line) => line.replace(/^#{1,6}\s+/, ''));
+ }
+
+ function historyDraftVersionLine(draft) {
+ const title = draft.title || draft.id || '下書き';
+ const version = draft.version ? `v${draft.version}` : draft.kind || '';
+ const score = draft.score === undefined ? '' : `score ${Number(draft.score).toFixed(1)}`;
+ const updatedAt = formatDateTime(draft.updatedAt || draft.createdAt);
+ return [version, title, score, updatedAt].filter(Boolean).join(' / ');
+ }
+
+ function historyDraftResumeText(draft) {
+ const score = draft.score === undefined ? '' : ` / style score ${Number(draft.score).toFixed(1)}`;
+ const version = draft.version ? ` v${draft.version}` : '';
+ return `保存済み下書き${version}をMarkdownエディタに反映しました${score}。`;
+ }
+
+ function markdownTitle(markdown) {
+ const heading = String(markdown || '').split('\n').find((line) => line.trim().startsWith('# '));
+ return heading ? heading.replace(/^#\s+/, '').trim() : '';
+ }
+
function applyPersonaDefaults(forceFormat) {
const persona = currentPersona();
From fff7a557bcd9b2cc820475644901ec3d729c4267 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 18:29:59 +0900
Subject: [PATCH 30/33] Add browser E2E coverage
Closes #13
---
.gitignore | 5 +
Makefile | 5 +-
...02-multi-persona-multi-format-extension.md | 8 +-
.../next-implementation-cut.md | 22 +-
...13-browser-contract-coverage-2026-05-03.md | 32 +-
.../issue-13-browser-e2e-2026-05-03.md | 51 ++
pytest.ini | 3 +
tests/e2e/conftest.py | 346 ++++++++++
tests/e2e/e2e_server.py | 164 +++++
tests/e2e/test_config_questions.py | 389 +++++++++++
tests/e2e/test_history_stream_regenerate.py | 650 ++++++++++++++++++
11 files changed, 1660 insertions(+), 15 deletions(-)
create mode 100644 docs/validation/issue-13-browser-e2e-2026-05-03.md
create mode 100644 pytest.ini
create mode 100644 tests/e2e/conftest.py
create mode 100644 tests/e2e/e2e_server.py
create mode 100644 tests/e2e/test_config_questions.py
create mode 100644 tests/e2e/test_history_stream_regenerate.py
diff --git a/.gitignore b/.gitignore
index 9c277ee..0d6cd4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,11 @@ logs/
vendor/
node_modules/
+# Python/E2E
+__pycache__/
+*.py[cod]
+.pytest_cache/
+
# その他
*.bak
*.tmp
diff --git a/Makefile b/Makefile
index 6aa69cd..059a486 100644
--- a/Makefile
+++ b/Makefile
@@ -40,7 +40,7 @@ LLAMACPP_HF_REPO ?= ggml-org/gemma-4-31B-it-GGUF
LLAMACPP_HF_FILE ?= gemma-4-31B-it-Q4_K_M.gguf
LLAMA_SERVER ?= llama-server
-.PHONY: app dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models scenario-evo-x2 scenario-media-matrix-live server llama check
+.PHONY: app dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models scenario-evo-x2 scenario-media-matrix-live server llama check e2e
app: dev
@@ -82,3 +82,6 @@ llama:
check:
go test ./...
+
+e2e:
+ python3 -m pytest tests/e2e
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 83edf59..b531271 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -222,8 +222,8 @@ Current implementation status as of 2026-05-03:
- Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)).
- Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)).
- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
-- The #13 follow-up has browser-adjacent contract coverage for the static HTML, script selectors, persistence hooks, workflow read routes, and artifact-card structure. It does not close #13: Playwright or equivalent real browser coverage is still required for persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and localStorage migration. Validation is recorded in [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
-- A Phase C project/article/draft history follow-up now builds on the #26 SQLite schema: `GET /api/workflow/artifacts` includes project/article/draft summaries when SQLite is active, focused read routes expose project/article/draft details, and the history UI renders project, article, brief, current draft, draft versions, and source snapshot cards. The handler/server/static tests are green, but until the browser flow is covered by #13, the canonical completed Phase C closure claim remains separate from full browser E2E.
+- 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.
- 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 +237,8 @@ Current implementation status as of 2026-05-03:
Near-term execution order:
1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check.
-2. Keep [#13](https://github.com/terisuke/note_maker/issues/13) open after the browser-contract pass and add real browser E2E coverage for history opening, readable cards, persona/format switching, question config, edit/fork, streaming/cancel, regenerate-section, and localStorage migration.
-3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 first cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, richer project/article/draft history polish, and browser E2E over the project history cards.
+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.
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
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 7619390..0600421 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -40,10 +40,16 @@ Implemented in the #13 follow-up contract cut:
- This is useful pre-Playwright coverage, but it is not equivalent to browser E2E. It does not exercise real DOM events, fetch stubbing, localStorage reload behavior, SSE/cancel, or the section-regeneration workflow in a browser.
- Validation — [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
+Implemented in the `codex/issue13-browser-e2e` cut:
+
+- Python `pytest` plus Playwright starts the real Go server on a free localhost port and stubs application APIs through browser route handlers.
+- Browser tests now cover model selector persistence, legacy localStorage migration, persona/format switching, custom question CRUD/reset, interview start payloads, answer SSE submission/cancel recovery, saved history opening/readable cards, edit/fork, draft streaming/cancel recovery, and section regeneration reject/accept.
+- Validation — [Issue #13 Browser E2E Validation](../validation/issue-13-browser-e2e-2026-05-03.md).
+
Open and active:
- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
-- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13), still open after the contract-test cut.
+- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13), closure-ready after the browser E2E cut lands.
- 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).
@@ -56,7 +62,7 @@ Remaining Phase C gaps after the current #27/#28 cut:
- Add-persona authoring UI is not implemented; the current UI consumes seeded personas and saved artifacts.
- Broader edit persistence called out in the issue text is not implemented beyond the existing fork-on-edit/session/brief save paths.
-- Project/article/draft artifact browsing from SQLite's normalized #26 schema now has server routes, response-shape contract coverage, frontend selectors, and readable history cards. The remaining gap is real browser E2E over those cards and broader edit/add-persona semantics, so #13/#14/#27/#28 should stay open unless the owner explicitly accepts a narrower first-cut closure.
+- 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.
## Current Review Findings
@@ -72,13 +78,13 @@ node --check static/js/script.js
git diff --check
```
-No blocking code-risk finding remains in the targeted suite after the parallel fixes. The remaining closure risk is coverage scope: #13 still lacks real browser E2E, and #27/#28 still contain product scope that is broader than the first cut.
+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.
## Issue Close/Open Proposal
| Issue | Proposal | Rationale |
|---|---|---|
-| [#13](https://github.com/terisuke/note_maker/issues/13) | Keep open | Static and route contract coverage was added, but the issue asks for browser E2E. Real browser coverage still needs model config, question customisation, persona/format switching, history open, readable cards, streaming/cancel, edit/fork, regenerate-section, and localStorage migration. |
+| [#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. |
@@ -155,14 +161,14 @@ Use subagents with disjoint write scopes when implementation resumes:
|---|---|---|---|---|
| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | done for this cut | style-guide/session history picker and readable brief/style cards use persisted workflow state |
-| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | contract tests are done; close only after persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and legacy localStorage migration are covered in a browser |
-| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing are split from the #27/#28 first cut |
+| 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 |
Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming/cancel, and regenerate-section coverage goals.
-2. Split the remaining Phase C product work into explicit follow-up issues before broadening implementation: add-persona authoring UI, broader edit persistence semantics, project/article/draft artifact browsing polish from the #26 SQLite schema, and real browser E2E over these history paths.
+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.
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 2738589..0e7b485 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
@@ -1,7 +1,7 @@
# Issue #13 Browser Contract Coverage
Date: 2026-05-03
-Branch: `codex/phase-c-history-e2e`
+Branch: `codex/phase-c-history-e2e`; reviewed again from `codex/issue13-browser-e2e`
## Scope
@@ -51,9 +51,37 @@ 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.
+## Browser E2E Follow-up
+
+Review date: 2026-05-03 on `codex/issue13-browser-e2e`.
+
+The follow-up cut adds real browser E2E coverage using Python `pytest` plus Playwright. It starts the real Go server on a free localhost port and uses Playwright route handlers to stub `/api/*` and the external `marked` CDN script. This keeps the tests deterministic and avoids Evo X2/local LLM calls while still exercising the browser DOM, production HTML, and production JavaScript.
+
+```sh
+python3 -m pytest tests/e2e -q
+```
+
+Result from the final local review:
+
+```text
+........ [100%]
+8 passed in 5.80s
+```
+
+Repository validation also passed:
+
+```sh
+go test ./...
+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.
+
## Issue Policy
-- #13: keep open. Contract coverage is useful, but the issue asks for browser E2E.
+- #13: close 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
new file mode 100644
index 0000000..01fd97e
--- /dev/null
+++ b/docs/validation/issue-13-browser-e2e-2026-05-03.md
@@ -0,0 +1,51 @@
+# Issue #13 Browser E2E Validation
+
+Date: 2026-05-03
+Branch: `codex/issue13-browser-e2e`
+
+## Scope
+
+This cut adds real browser E2E coverage for Issue #13 using Python `pytest` plus Playwright. The tests start the real Go web server on a free localhost port and stub application API calls through Playwright route handlers, so the browser exercises the production `static/index.html` and `static/js/script.js` without connecting to Evo X2 or any local LLM.
+
+## Implementation
+
+- `tests/e2e/e2e_server.py` starts `go run ./cmd/server` on an ephemeral port with temporary workflow/config storage and LLM endpoints disabled.
+- `tests/e2e/conftest.py` provides `base_url`, `page`, and `api_stub` fixtures and replaces the external `marked` CDN script with a local browser stub.
+- `tests/e2e/test_config_questions.py` covers model selector persistence/reload restore, persona/output-format template switching, custom question add/edit/delete/reset, legacy `localStorage` migration, preset style seeding, interview start payloads, and answer SSE submission.
+- `tests/e2e/test_history_stream_regenerate.py` covers saved history opening, readable history cards, answer edit/fork, answer and draft streaming/cancel recovery, and section regeneration reject/accept.
+- Missing Playwright or browser binaries fail the E2E target instead of reporting a skipped green run.
+- `make e2e` runs the browser suite.
+
+## Validation
+
+```sh
+python3 -m pytest tests/e2e -q
+go test ./...
+git diff --check
+```
+
+Result from the final local review:
+
+```text
+........ [100%]
+8 passed in 5.80s
+go test ./... passed
+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:
+
+- model dropdown population and persisted phase-model choices;
+- custom question add/edit/delete/reset;
+- legacy `questions` localStorage migration to `customQuestions`;
+- persona and output-format switching with template reloads;
+- starting an interview with custom questions in the `/api/brief-sessions` payload;
+- answer SSE submission and cancel recovery;
+- saved history style/session/project/article/draft opening and readable cards;
+- edit-and-fork endpoint behavior;
+- 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.
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..9c4d73d
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+markers =
+ e2e: browser end-to-end tests that run the real Go server with Playwright-routed API stubs
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 0000000..ba8d633
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,346 @@
+from __future__ import annotations
+
+import json
+import os
+from fnmatch import fnmatch
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+import pytest
+
+from e2e_server import E2EServer, start_server
+
+
+MARKED_CDN_PATTERN = "**/cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
+MARKED_STUB = r"""
+(() => {
+ const escapeHTML = (value) => String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ const inline = (value) => escapeHTML(value)
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ .replace(/`([^`]+)`/g, '$1');
+ window.marked = {
+ parse(markdown) {
+ return String(markdown ?? '')
+ .split(/\n{2,}/)
+ .filter((block) => block.trim())
+ .map((block) => {
+ const text = block.trim();
+ const heading = /^(#{1,6})\s+(.+)$/.exec(text);
+ if (heading) {
+ const level = heading[1].length;
+ return `${inline(heading[2])} `;
+ }
+ return `${inline(text).replace(/\n/g, ' ')}
`;
+ })
+ .join('\n');
+ },
+ };
+})();
+""".strip()
+
+
+class ApiStubber:
+ def __init__(self, context: Any) -> None:
+ self._context = context
+ self._stubs: list[dict[str, Any]] = []
+ self.requests: list[dict[str, Any]] = []
+ self.install_default_stubs()
+ context.route("**/api/**", self._handle)
+
+ def install_default_stubs(self) -> None:
+ self.stub_json("GET", "/api/models", ["gemma4:31b", "gemma4:e2b", "gemma4:latest"])
+ self.stub_json("GET", "/api/personas", [default_persona()])
+ self.stub_json("GET", "/api/formats", [default_format()])
+ self.stub_json("GET", "/api/config/storage", default_storage_config())
+ self.stub_json("PATCH", "/api/config/storage", default_storage_config())
+ self.stub_json("GET", "/api/brief-sessions/templates", default_question_template())
+ self.stub_json("GET", "/api/workflow/artifacts", default_workflow_artifacts())
+ self.stub_json("GET", "/api/history", default_workflow_artifacts())
+ self.stub_json("GET", "/api/author-style", {"style_guides": []})
+ self.stub_json("GET", "/api/brief-sessions", {"sessions": []})
+ self.stub_json("GET", "/api/briefs", {"briefs": []})
+ self.stub_json("GET", "/api/projects", {"projects": []})
+
+ def stub_json(
+ self,
+ method: str,
+ path: str,
+ payload: Any,
+ *,
+ status: int = 200,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self._add_stub(
+ method,
+ path,
+ status=status,
+ headers={"Content-Type": "application/json", **(headers or {})},
+ body=json.dumps(payload),
+ )
+
+ def stub_error(
+ self,
+ method: str,
+ path: str,
+ *,
+ status: int = 500,
+ message: str = "stubbed API error",
+ code: str = "E2E_STUB_ERROR",
+ ) -> None:
+ self.stub_json(method, path, {"error": {"code": code, "message": message}}, status=status)
+
+ def stub_sse(
+ self,
+ method: str,
+ path: str,
+ events: list[dict[str, Any]],
+ *,
+ status: int = 200,
+ ) -> None:
+ body = "".join(_format_sse_event(event) for event in events)
+ self._add_stub(
+ method,
+ path,
+ status=status,
+ headers={"Content-Type": "text/event-stream"},
+ body=body,
+ )
+
+ def clear(self) -> None:
+ self._stubs.clear()
+
+ def reset(self) -> None:
+ self.clear()
+ self.install_default_stubs()
+
+ def _add_stub(
+ self,
+ method: str,
+ path: str,
+ *,
+ status: int,
+ headers: dict[str, str],
+ body: str,
+ ) -> None:
+ self._stubs.insert(
+ 0,
+ {
+ "method": method.upper(),
+ "path": path,
+ "status": status,
+ "headers": headers,
+ "body": body,
+ },
+ )
+
+ def _handle(self, route: Any) -> None:
+ request = route.request
+ parsed = urlparse(request.url)
+ path_with_query = parsed.path + (f"?{parsed.query}" if parsed.query else "")
+ record = {
+ "method": request.method,
+ "path": parsed.path,
+ "query": parsed.query,
+ "url": request.url,
+ "post_data": request.post_data,
+ }
+ self.requests.append(record)
+
+ for stub in self._stubs:
+ if request.method.upper() != stub["method"]:
+ continue
+ if _path_matches(stub["path"], path_with_query, parsed.path):
+ route.fulfill(status=stub["status"], headers=stub["headers"], body=stub["body"])
+ return
+
+ route.fulfill(
+ status=501,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps(
+ {
+ "error": {
+ "code": "E2E_API_STUB_MISSING",
+ "message": f"No E2E stub registered for {request.method} {path_with_query}",
+ }
+ }
+ ),
+ )
+
+
+def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
+ for item in items:
+ if f"{os.sep}tests{os.sep}e2e{os.sep}" in str(item.path):
+ item.add_marker(pytest.mark.e2e)
+
+
+@pytest.fixture(scope="session")
+def repo_root() -> Path:
+ return Path(__file__).resolve().parents[2]
+
+
+@pytest.fixture(scope="session")
+def e2e_server(repo_root: Path, tmp_path_factory: pytest.TempPathFactory) -> E2EServer:
+ server = start_server(repo_root, tmp_path_factory.mktemp("note-maker-e2e-server"))
+ try:
+ yield server
+ finally:
+ server.stop()
+
+
+@pytest.fixture(scope="session")
+def base_url(e2e_server: E2EServer) -> str:
+ return e2e_server.base_url
+
+
+@pytest.fixture(scope="session")
+def playwright_instance() -> Any:
+ try:
+ from playwright.sync_api import sync_playwright
+ except ImportError as exc:
+ pytest.fail(
+ f"Playwright is required for browser E2E tests but is not installed: {exc}",
+ pytrace=False,
+ )
+
+ with sync_playwright() as playwright:
+ yield playwright
+
+
+@pytest.fixture(scope="session")
+def browser(playwright_instance: Any) -> Any:
+ from playwright.sync_api import Error as PlaywrightError
+
+ browser_name = os.environ.get("E2E_BROWSER", "chromium")
+ launcher = getattr(playwright_instance, browser_name, None)
+ if launcher is None:
+ pytest.fail(f"Unsupported Playwright browser: {browser_name}", pytrace=False)
+
+ headless = os.environ.get("E2E_HEADLESS", "1").lower() not in {"0", "false", "no"}
+ try:
+ browser = launcher.launch(headless=headless)
+ except PlaywrightError as exc:
+ pytest.fail(
+ f"Playwright {browser_name} is required for browser E2E tests but cannot start: {exc}",
+ pytrace=False,
+ )
+
+ try:
+ yield browser
+ finally:
+ browser.close()
+
+
+@pytest.fixture()
+def context(browser: Any, base_url: str) -> Any:
+ context = browser.new_context(base_url=base_url)
+ context.route(MARKED_CDN_PATTERN, _fulfill_marked_stub)
+ try:
+ yield context
+ finally:
+ context.close()
+
+
+@pytest.fixture()
+def api_stub(context: Any) -> ApiStubber:
+ return ApiStubber(context)
+
+
+@pytest.fixture()
+def api_stubs(api_stub: ApiStubber) -> ApiStubber:
+ return api_stub
+
+
+@pytest.fixture()
+def page(context: Any, api_stub: ApiStubber) -> Any:
+ return context.new_page()
+
+
+def default_persona() -> dict[str, Any]:
+ return {
+ "id": "terisuke",
+ "display_name": "E2E Terisuke",
+ "description": "Browser E2E persona",
+ "default_format": "note_article",
+ "sources": [{"kind": "note", "ref": "e2e_writer", "url": "https://example.test/rss"}],
+ "voice_notes": {"first_person": ["私"], "tone": "calm", "title_patterns": []},
+ }
+
+
+def default_format() -> dict[str, Any]:
+ return {
+ "id": "note_article",
+ "display_name": "note",
+ "description": "E2E note format",
+ "extension": ".md",
+ }
+
+
+def default_storage_config() -> dict[str, Any]:
+ return {
+ "active_driver": "json",
+ "active_path": "tmp/e2e-workflow-store.json",
+ "configured_driver": "json",
+ "configured_path": "tmp/e2e-workflow-store.json",
+ "config_path": "tmp/e2e-app-config.json",
+ "env_locked": False,
+ "restart_required": False,
+ "restart_message": "現在の保存方式で動作中です。",
+ "effective_next_boot": True,
+ }
+
+
+def default_question_template() -> dict[str, Any]:
+ return {
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "questions": [
+ {
+ "id": "theme",
+ "text": "この記事で伝えたいことは何ですか?",
+ "flow_type": "main",
+ "target_field": "theme",
+ "required": True,
+ }
+ ],
+ }
+
+
+def default_workflow_artifacts() -> dict[str, Any]:
+ return {
+ "style_guides": [],
+ "sessions": [],
+ "briefs": [],
+ "projects": [],
+ "articles": [],
+ "drafts": [],
+ }
+
+
+def _path_matches(pattern: str, path_with_query: str, path_only: str) -> bool:
+ return (
+ pattern == path_with_query
+ or pattern == path_only
+ or fnmatch(path_with_query, pattern)
+ or fnmatch(path_only, pattern)
+ )
+
+
+def _format_sse_event(event: dict[str, Any]) -> str:
+ event_name = event.get("event", "message")
+ data = event.get("data", {})
+ encoded = data if isinstance(data, str) else json.dumps(data)
+ return f"event: {event_name}\ndata: {encoded}\n\n"
+
+
+def _fulfill_marked_stub(route: Any) -> None:
+ route.fulfill(
+ status=200,
+ headers={"Content-Type": "application/javascript; charset=utf-8"},
+ body=MARKED_STUB,
+ )
diff --git a/tests/e2e/e2e_server.py b/tests/e2e/e2e_server.py
new file mode 100644
index 0000000..46b9e7e
--- /dev/null
+++ b/tests/e2e/e2e_server.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import os
+import signal
+import socket
+import subprocess
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Mapping
+
+
+HOST = "127.0.0.1"
+
+
+@dataclass(frozen=True)
+class E2EServer:
+ base_url: str
+ port: int
+ log_path: Path
+ _process: subprocess.Popen[bytes]
+
+ @property
+ def url(self) -> str:
+ return self.base_url
+
+ def read_log(self) -> str:
+ return self.log_path.read_text(encoding="utf-8", errors="replace")
+
+ def stop(self) -> None:
+ if self._process.poll() is not None:
+ return
+ try:
+ if hasattr(os, "killpg"):
+ os.killpg(self._process.pid, signal.SIGTERM)
+ else:
+ self._process.terminate()
+ self._process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ if hasattr(os, "killpg"):
+ os.killpg(self._process.pid, signal.SIGKILL)
+ else:
+ self._process.kill()
+ self._process.wait(timeout=5)
+ except ProcessLookupError:
+ pass
+
+
+def find_free_port(host: str = HOST) -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind((host, 0))
+ return int(sock.getsockname()[1])
+
+
+def start_server(
+ repo_root: Path,
+ tmp_dir: Path,
+ *,
+ host: str = HOST,
+ timeout_seconds: float = 30.0,
+ extra_env: Mapping[str, str] | None = None,
+) -> E2EServer:
+ port = find_free_port(host)
+ base_url = f"http://{host}:{port}"
+ log_file = tempfile.NamedTemporaryFile(
+ prefix="note-maker-e2e-server-",
+ suffix=".log",
+ delete=False,
+ )
+ log_path = Path(log_file.name)
+
+ env = _server_env(port, tmp_dir)
+ if extra_env:
+ env.update(extra_env)
+
+ process = subprocess.Popen(
+ ["go", "run", "./cmd/server"],
+ cwd=repo_root,
+ env=env,
+ stdout=log_file,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ server = E2EServer(base_url=base_url, port=port, log_path=log_path, _process=process)
+
+ try:
+ _wait_until_ready(server, timeout_seconds)
+ except Exception:
+ server.stop()
+ raise
+ finally:
+ log_file.close()
+
+ return server
+
+
+def _server_env(port: int, tmp_dir: Path) -> dict[str, str]:
+ env = os.environ.copy()
+ env.update(
+ {
+ "PORT": str(port),
+ "NOTE_MAKER_CONFIG_PATH": str(tmp_dir / "app_config.json"),
+ "WORKFLOW_STORE_DRIVER": "json",
+ "WORKFLOW_STORE_PATH": str(tmp_dir / "workflow_store.json"),
+ "LLM_BASE_URL": "http://127.0.0.1:1/v1",
+ "LLAMACPP_BASE_URL": "http://127.0.0.1:1/v1",
+ "LLM_MODEL": "e2e-stubbed-model",
+ "STYLE_LLM_MODEL": "e2e-stubbed-style",
+ "BRIEF_LLM_MODEL": "e2e-stubbed-brief",
+ "ARTICLE_LLM_MODEL": "e2e-stubbed-article",
+ "DRAFT_LLM_MODEL": "e2e-stubbed-draft",
+ "VERIFY_LLM_MODEL": "e2e-stubbed-verify",
+ "LLM_TIMEOUT_SECONDS": "1",
+ "LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS": "1",
+ "LLM_STREAM_IDLE_TIMEOUT_SECONDS": "1",
+ }
+ )
+ for name in (
+ "LLM_FALLBACK_BASE_URLS",
+ "FALLBACK_LLM_BASE_URLS",
+ "FALLBACK_LLM_BASE_URL",
+ "STYLE_LLM_FALLBACK_BASE_URLS",
+ "STYLE_FALLBACK_LLM_BASE_URLS",
+ "BRIEF_LLM_FALLBACK_BASE_URLS",
+ "BRIEF_FALLBACK_LLM_BASE_URLS",
+ "ARTICLE_LLM_FALLBACK_BASE_URLS",
+ "ARTICLE_FALLBACK_LLM_BASE_URLS",
+ "DRAFT_LLM_FALLBACK_BASE_URLS",
+ "DRAFT_FALLBACK_LLM_BASE_URLS",
+ "VERIFY_LLM_FALLBACK_BASE_URLS",
+ "VERIFY_FALLBACK_LLM_BASE_URLS",
+ ):
+ env[name] = ""
+ return env
+
+
+def _wait_until_ready(server: E2EServer, timeout_seconds: float) -> None:
+ deadline = time.monotonic() + timeout_seconds
+ last_error: BaseException | None = None
+
+ while time.monotonic() < deadline:
+ if server._process.poll() is not None:
+ raise RuntimeError(
+ "E2E server exited before it became ready.\n"
+ f"Exit code: {server._process.returncode}\n"
+ f"Log:\n{server.read_log()}"
+ )
+ try:
+ with urllib.request.urlopen(server.base_url + "/", timeout=0.5) as response:
+ if response.status < 500:
+ return
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ last_error = exc
+ time.sleep(0.1)
+
+ raise TimeoutError(
+ f"E2E server did not become ready at {server.base_url} within {timeout_seconds:.1f}s.\n"
+ f"Last error: {last_error}\n"
+ f"Log:\n{server.read_log()}"
+ )
diff --git a/tests/e2e/test_config_questions.py b/tests/e2e/test_config_questions.py
new file mode 100644
index 0000000..ff0cbd5
--- /dev/null
+++ b/tests/e2e/test_config_questions.py
@@ -0,0 +1,389 @@
+import json
+import re
+from urllib.parse import parse_qs, urlparse
+
+from playwright.sync_api import Page, expect
+
+
+CONFIG_KEY = "note-maker-config-v1"
+
+MODELS = [
+ "gemma4:e2b",
+ "qwen3.6:27b",
+ "gemma4:31b",
+ "gemma4:latest",
+ "llama3.2:8b",
+]
+
+PERSONAS = [
+ {
+ "id": "terisuke",
+ "display_name": "Terisuke",
+ "description": "Practical local-first engineering notes.",
+ "default_format": "note_article",
+ "voice_notes": {"first_person": ["僕"]},
+ "sources": [
+ {"kind": "note", "ref": "cor_instrument"},
+ {"kind": "github", "ref": "Cor-Incorporated/corsweb2024/src/content/blog/ja"},
+ ],
+ },
+ {
+ "id": "cloudia",
+ "display_name": "Cloudia",
+ "description": "Technical cloud and platform writing.",
+ "default_format": "zenn_article",
+ "voice_notes": {"first_person": ["私たち"]},
+ "sources": [
+ {"kind": "zenn", "ref": "cloudia"},
+ {"kind": "qiita", "ref": "Cloudia_Cor_Inc"},
+ ],
+ },
+]
+
+FORMATS = [
+ {"id": "note_article", "display_name": "note"},
+ {"id": "markdown_blog", "display_name": "Company Blog"},
+ {"id": "zenn_article", "display_name": "Zenn"},
+ {"id": "qiita_article", "display_name": "Qiita"},
+]
+
+
+def question(question_id, text, required=True):
+ return {
+ "id": question_id,
+ "text": text,
+ "flow_type": "main",
+ "target_field": question_id,
+ "required": required,
+ }
+
+
+TEMPLATES = {
+ ("terisuke", "note_article"): [
+ question("theme", "何について書きますか?"),
+ question("opening_episode", "冒頭で使う具体的な出来事は?"),
+ ],
+ ("terisuke", "markdown_blog"): [
+ question("theme", "ブログ記事のテーマは?"),
+ question("cor_blog_purpose", "会社ブログで達成したい目的は?"),
+ ],
+ ("cloudia", "zenn_article"): [
+ question("theme", "技術記事のテーマは?"),
+ question("target_stack", "対象スタックは?"),
+ question("cloudia_viewpoint", "Cloudiaとして強調する視点は?", required=False),
+ ],
+ ("cloudia", "qiita_article"): [
+ question("theme", "Qiita記事のテーマは?"),
+ question("code_examples", "含めたいコード例は?"),
+ question("cloudia_viewpoint", "Cloudiaとして強調する視点は?", required=False),
+ ],
+}
+
+
+def test_model_config_persists_and_restores_from_local_storage(page: Page, base_url: str):
+ install_app_routes(page)
+
+ page.goto(app_url(base_url))
+ wait_for_config_loaded(page)
+
+ page.select_option("#style-model", "llama3.2:8b")
+ page.select_option("#brief-model", "gemma4:31b")
+ page.select_option("#draft-model", "qwen3.6:27b")
+ page.select_option("#verify-model", "gemma4:e2b")
+
+ saved = read_config(page)
+ assert saved["models"] == {
+ "style": "llama3.2:8b",
+ "brief": "gemma4:31b",
+ "draft": "qwen3.6:27b",
+ "verify": "gemma4:e2b",
+ }
+
+ page.reload()
+ wait_for_config_loaded(page)
+
+ expect(page.locator("#style-model")).to_have_value("llama3.2:8b")
+ expect(page.locator("#brief-model")).to_have_value("gemma4:31b")
+ expect(page.locator("#draft-model")).to_have_value("qwen3.6:27b")
+ expect(page.locator("#verify-model")).to_have_value("gemma4:e2b")
+
+
+def test_persona_format_template_switching_and_custom_question_crud_migration(
+ page: Page,
+ base_url: str,
+):
+ captured = install_app_routes(page)
+ page.add_init_script(
+ """
+ localStorage.setItem("note-maker-config-v1", JSON.stringify({
+ mode: { persona: "cloudia", format: "qiita_article" },
+ models: { style: "gemma4:e2b", brief: "qwen3.6:27b", draft: "gemma4:31b", verify: "gemma4:latest" },
+ questions: [
+ { id: "theme", text: "legacy template should not survive" },
+ { id: "legacy_custom", text: "Migrated custom question", flow_type: "main", target_field: "custom" }
+ ]
+ }));
+ """
+ )
+
+ page.goto(app_url(base_url))
+ wait_for_config_loaded(page)
+ expect(page.locator("#question-config-list")).to_contain_text("Qiita記事のテーマは?")
+ expect(page.locator("#question-config-list")).to_contain_text("含めたいコード例は?")
+ expect(page.locator('input[aria-label="追加質問"]')).to_have_value("Migrated custom question")
+ expect(page.locator("#question-config-list")).not_to_contain_text("legacy template should not survive")
+
+ migrated = read_config(page)
+ assert "questions" not in migrated
+ assert migrated["customQuestions"] == [
+ {
+ "id": "legacy_custom",
+ "text": "Migrated custom question",
+ "flow_type": "main",
+ "target_field": "custom",
+ }
+ ]
+
+ page.click("#add-question-btn")
+ custom_inputs = page.locator('input[aria-label="追加質問"]')
+ expect(custom_inputs).to_have_count(2)
+ custom_inputs.nth(1).fill("What operational risk should the article cover?")
+
+ saved_after_add = read_config(page)
+ assert [item["text"] for item in saved_after_add["customQuestions"]] == [
+ "Migrated custom question",
+ "What operational risk should the article cover?",
+ ]
+
+ page.locator("#question-config-list .question-config-row.custom").nth(0).get_by_role(
+ "button",
+ name="削除",
+ ).click()
+ expect(page.locator('input[aria-label="追加質問"]')).to_have_value(
+ "What operational risk should the article cover?"
+ )
+ assert [item["text"] for item in read_config(page)["customQuestions"]] == [
+ "What operational risk should the article cover?"
+ ]
+
+ page.select_option("#persona-select", "terisuke")
+ expect(page.locator("#format-select")).to_have_value("note_article")
+ expect(page.locator("#mode-summary")).to_contain_text("Terisuke × note")
+ expect(page.locator("#question-config-list")).to_contain_text("何について書きますか?")
+
+ page.select_option("#format-select", "markdown_blog")
+ expect(page.locator("#mode-summary")).to_contain_text("Terisuke × Company Blog")
+ expect(page.locator("#question-config-list")).to_contain_text("会社ブログで達成したい目的は?")
+
+ page.click("#reset-questions-btn")
+ expect(page.locator('input[aria-label="追加質問"]')).to_have_count(0)
+ expect(page.locator("#question-config-list")).to_contain_text("会社ブログで達成したい目的は?")
+ assert read_config(page)["customQuestions"] == []
+ assert ("terisuke", "markdown_blog") in captured["template_requests"]
+
+
+def test_start_interview_sends_custom_questions_in_payload(page: Page, base_url: str):
+ captured = install_app_routes(page)
+
+ page.goto(app_url(base_url))
+ wait_for_config_loaded(page)
+
+ page.select_option("#persona-select", "cloudia")
+ expect(page.locator("#format-select")).to_have_value("zenn_article")
+ expect(page.locator("#question-config-list")).to_contain_text("対象スタックは?")
+
+ page.select_option("#brief-model", "qwen3.6:27b")
+ page.click("#add-question-btn")
+ page.locator('input[aria-label="追加質問"]').last.fill("Which migration trap should be explained?")
+ page.click("#add-question-btn")
+ page.locator('input[aria-label="追加質問"]').last.fill("What rollback signal should readers monitor?")
+
+ page.click("#use-preset-style-btn")
+ expect(page.locator("#start-interview-btn")).to_be_enabled()
+ page.click("#start-interview-btn")
+
+ expect(page.locator("#interview-area")).to_be_visible()
+ payload = captured["session_requests"][0]
+ assert payload["style_profile_id"] == "profile-cloudia-zenn_article"
+ assert payload["persona_id"] == "cloudia"
+ assert payload["output_format_id"] == "zenn_article"
+ assert payload["brief_model"] == "qwen3.6:27b"
+ assert [item["text"] for item in payload["questions"]] == [
+ "Which migration trap should be explained?",
+ "What rollback signal should readers monitor?",
+ ]
+ assert {item["target_field"] for item in payload["questions"]} == {"custom"}
+ assert "target_stack" not in {item["id"] for item in payload["questions"]}
+
+ expect(page.locator("#question-log")).to_contain_text("技術記事のテーマは?")
+
+ page.fill("#answer-input", "A deterministic browser route stub captured this answer.")
+ page.click("#submit-answer-btn")
+ expect(page.locator("#question-log")).to_contain_text("対象スタックは?")
+ assert captured["answer_requests"][0]["brief_model"] == "qwen3.6:27b"
+
+
+def install_app_routes(page: Page):
+ captured = {
+ "template_requests": [],
+ "seed_requests": [],
+ "session_requests": [],
+ "answer_requests": [],
+ }
+
+ page.route(
+ "https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js",
+ lambda route: route.fulfill(
+ status=200,
+ content_type="application/javascript",
+ body="window.marked = { parse: (value) => String(value || '') };",
+ ),
+ )
+ page.route("**/api/**", lambda route: dispatch_api(route, page, captured))
+ return captured
+
+
+def dispatch_api(route, page: Page, captured):
+ request = route.request
+ parsed = urlparse(request.url)
+ path = parsed.path
+ method = request.method
+
+ if method == "GET" and path == "/api/models":
+ fulfill_json(route, MODELS)
+ return
+
+ if method == "GET" and path == "/api/personas":
+ fulfill_json(route, PERSONAS)
+ return
+
+ if method == "GET" and path == "/api/formats":
+ fulfill_json(route, FORMATS)
+ return
+
+ if method == "GET" and path == "/api/config/storage":
+ fulfill_json(
+ route,
+ {
+ "active_driver": "json",
+ "active_path": "tmp/e2e-workflow-store.json",
+ "configured_driver": "json",
+ "configured_path": "tmp/e2e-workflow-store.json",
+ "env_locked": False,
+ "restart_required": False,
+ "restart_message": "",
+ },
+ )
+ return
+
+ if method == "GET" and path == "/api/workflow/artifacts":
+ fulfill_json(
+ route,
+ {
+ "projects": [],
+ "articles": [],
+ "drafts": [],
+ "author_styles": [],
+ "brief_sessions": [],
+ },
+ )
+ return
+
+ if method == "GET" and path == "/api/brief-sessions/templates":
+ query = parse_qs(parsed.query)
+ persona_id = query.get("persona_id", ["terisuke"])[0]
+ format_id = query.get("format_id", ["note_article"])[0]
+ captured["template_requests"].append((persona_id, format_id))
+ fulfill_json(route, {"questions": TEMPLATES.get((persona_id, format_id), [])})
+ return
+
+ if method == "POST" and path == "/api/author-style/seed":
+ body = request_json(request)
+ captured["seed_requests"].append(body)
+ persona_id = body.get("persona_id", "terisuke")
+ format_id = body.get("output_format_id", "note_article")
+ fulfill_json(
+ route,
+ {
+ "profile_id": f"profile-{persona_id}-{format_id}",
+ "guide_id": f"guide-{persona_id}-{format_id}",
+ "article_count": 3,
+ "guide_markdown": "# Style Guide\n- Keep claims concrete.\n- Match selected persona.",
+ },
+ )
+ return
+
+ if method == "POST" and path == "/api/brief-sessions":
+ body = request_json(request)
+ captured["session_requests"].append(body)
+ questions = TEMPLATES.get((body.get("persona_id"), body.get("output_format_id")), [])
+ fulfill_json(
+ route,
+ {
+ "session_id": "session-browser-e2e",
+ "parent_session_id": "",
+ "next_question": questions[0],
+ "answers": [],
+ },
+ )
+ return
+
+ if method == "POST" and re.fullmatch(r"/api/brief-sessions/[^/]+/answers", path):
+ body = request_json(request)
+ captured["answer_requests"].append(body)
+ payload = {
+ "session_id": "session-browser-e2e",
+ "parent_session_id": "",
+ "completed": False,
+ "answers": [
+ {
+ "question_id": "theme",
+ "content": body.get("content", ""),
+ "flow_type": "main",
+ }
+ ],
+ "next_question": question("target_stack", "対象スタックは?"),
+ }
+ route.fulfill(
+ status=200,
+ content_type="text/event-stream",
+ body=f"event: result\ndata: {json.dumps(payload)}\n\n",
+ )
+ return
+
+ route.fulfill(
+ status=404,
+ content_type="application/json",
+ body=json.dumps({"error": {"message": f"unexpected {method} {path}"}}),
+ )
+
+
+def fulfill_json(route, value, status=200):
+ route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(value),
+ )
+
+
+def request_json(request):
+ return json.loads(request.post_data or "{}")
+
+
+def app_url(base_url: str) -> str:
+ return base_url.rstrip("/") + "/"
+
+
+def wait_for_config_loaded(page: Page):
+ page.wait_for_function(
+ """
+ () => document.querySelector("#style-model")?.options.length > 0
+ && document.querySelector("#persona-select")?.options.length > 0
+ && document.querySelector("#format-select")?.options.length > 0
+ && !document.querySelector("#question-config-list")?.textContent.includes("読み込んでいます")
+ """
+ )
+
+
+def read_config(page: Page):
+ return page.evaluate(f"JSON.parse(localStorage.getItem({json.dumps(CONFIG_KEY)}))")
diff --git a/tests/e2e/test_history_stream_regenerate.py b/tests/e2e/test_history_stream_regenerate.py
new file mode 100644
index 0000000..52734bc
--- /dev/null
+++ b/tests/e2e/test_history_stream_regenerate.py
@@ -0,0 +1,650 @@
+import json
+import re
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+from playwright.sync_api import Page, Route, Request, expect
+
+
+MARKED_STUB = """
+window.marked = {
+ parse(markdown) {
+ return String(markdown || '')
+ .split(/\\n{2,}/)
+ .map((block) => {
+ const escaped = block
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+ if (escaped.startsWith('# ')) {
+ return `${escaped.slice(2)} `;
+ }
+ if (escaped.startsWith('## ')) {
+ return `${escaped.slice(3)} `;
+ }
+ return `${escaped.replaceAll('\\n', ' ')}
`;
+ })
+ .join('');
+ },
+};
+"""
+
+
+PERSONAS = [
+ {
+ "id": "terisuke",
+ "display_name": "Terisuke",
+ "description": "Local writing persona",
+ "default_format": "note_article",
+ "voice_notes": {"first_person": ["私"]},
+ "sources": [{"kind": "note", "ref": "terisuke"}],
+ }
+]
+
+FORMATS = [{"id": "note_article", "display_name": "note"}]
+MODELS = ["style-e2e", "brief-e2e", "draft-e2e", "verify-e2e"]
+QUESTIONS = [
+ {
+ "id": "theme",
+ "text": "この記事で一番伝えたいことは?",
+ "flow_type": "main",
+ "target_field": "theme",
+ "required": True,
+ },
+ {
+ "id": "reader",
+ "text": "想定読者は誰ですか?",
+ "flow_type": "main",
+ "target_field": "reader",
+ "required": True,
+ },
+]
+
+STYLE_DETAIL = {
+ "profile_id": "style-history",
+ "guide_id": "guide-history",
+ "title": "履歴文体ガイド",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "article_count": 3,
+ "guide_markdown": "# 履歴文体ガイド\n\n- 具体例から始める\n- 読者の不安を短く受け止める",
+}
+
+BRIEF = {
+ "theme": "履歴から再開する記事",
+ "reader": "保存済みプロジェクトを確認する編集者",
+ "opening_episode": "朝のレビューで履歴を開く",
+ "expected_reader_action": "途中から安全に再開する",
+ "must_include": "文体、取材、下書きの状態",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "style_profile_id": "style-history",
+}
+
+SESSION_DETAIL = {
+ "session_id": "session-history",
+ "title": "履歴取材セッション",
+ "style_profile_id": "style-history",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "completed": True,
+ "brief": BRIEF,
+ "questions": QUESTIONS,
+ "answers": [
+ {
+ "question_id": "theme",
+ "content": "Original history answer",
+ "flow_type": "main",
+ }
+ ],
+}
+
+DRAFT_MARKDOWN = """# 履歴下書き
+
+## 背景
+
+古い背景です。
+
+## Target Section
+
+古い対象セクションです。
+
+## まとめ
+
+古いまとめです。
+"""
+
+DRAFT_DETAIL = {
+ "draft_id": "draft-history",
+ "article_id": "article-history",
+ "session_id": "session-history",
+ "style_profile_id": "style-history",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "version": 2,
+ "score": 4.2,
+ "status": "passed",
+ "markdown": DRAFT_MARKDOWN,
+}
+
+ARTICLE_DETAIL = {
+ "article_id": "article-history",
+ "project_id": "project-history",
+ "title": "履歴記事",
+ "session_id": "session-history",
+ "style_profile_id": "style-history",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "status": "drafted",
+ "brief": BRIEF,
+ "current_draft": DRAFT_DETAIL,
+ "draft_versions": [DRAFT_DETAIL],
+ "source_snapshot": {
+ "title": "参照ソース",
+ "fetched_at": "2026-05-01T08:00:00Z",
+ "articles": [
+ {
+ "title": "参考記事A",
+ "url": "https://example.test/reference-a",
+ "fetched_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+ },
+}
+
+PROJECT_DETAIL = {
+ "project_id": "project-history",
+ "title": "履歴プロジェクト",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "status": "active",
+ "article_count": 1,
+ "articles": [ARTICLE_DETAIL],
+}
+
+HISTORY_INDEX = {
+ "style_guides": [
+ {
+ "profile_id": "style-history",
+ "title": "履歴文体ガイド",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "updated_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+ "sessions": [
+ {
+ "session_id": "session-history",
+ "title": "履歴取材セッション",
+ "style_profile_id": "style-history",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "completed": True,
+ "updated_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+ "projects": [
+ {
+ "project_id": "project-history",
+ "title": "履歴プロジェクト",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "article_count": 1,
+ "updated_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+ "articles": [
+ {
+ "article_id": "article-history",
+ "project_id": "project-history",
+ "title": "履歴記事",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "updated_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+ "drafts": [
+ {
+ "draft_id": "draft-history",
+ "article_id": "article-history",
+ "session_id": "session-history",
+ "style_profile_id": "style-history",
+ "title": "履歴下書き",
+ "persona_id": "terisuke",
+ "output_format_id": "note_article",
+ "version": 2,
+ "updated_at": "2026-05-01T08:00:00Z",
+ }
+ ],
+}
+
+
+@dataclass
+class StubState:
+ calls: list[dict[str, Any]] = field(default_factory=list)
+ pending_routes: dict[str, list[Route]] = field(default_factory=dict)
+
+ def record(self, request: Request, path: str) -> dict[str, Any]:
+ payload = request.post_data_json if request.post_data else None
+ call = {
+ "method": request.method,
+ "path": path,
+ "query": parse_qs(urlparse(request.url).query),
+ "headers": {key.lower(): value for key, value in request.headers.items()},
+ "payload": payload,
+ }
+ self.calls.append(call)
+ return call
+
+ 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:
+ state = StubState()
+ handlers = handlers or {}
+
+ 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(
+ status=200,
+ content_type="application/javascript",
+ body=MARKED_STUB,
+ ),
+ )
+
+ def api_handler(route: Route, request: Request) -> None:
+ parsed = urlparse(request.url)
+ path = parsed.path
+ call = state.record(request, path)
+
+ if path in handlers:
+ response = handlers[path](route, request, call, state)
+ if response is None:
+ return
+ fulfill_json(route, response)
+ return
+
+ if path == "/api/personas":
+ fulfill_json(route, PERSONAS)
+ elif path == "/api/formats":
+ fulfill_json(route, FORMATS)
+ elif path == "/api/models":
+ fulfill_json(route, MODELS)
+ elif path == "/api/config/storage":
+ fulfill_json(
+ route,
+ {
+ "active_driver": "json",
+ "active_path": "data/workflow_store.json",
+ "configured_driver": "json",
+ "configured_path": "data/workflow_store.json",
+ "env_locked": False,
+ "restart_required": False,
+ },
+ )
+ elif path == "/api/brief-sessions/templates":
+ fulfill_json(route, {"questions": QUESTIONS})
+ elif path == "/api/workflow/artifacts":
+ fulfill_json(route, HISTORY_INDEX)
+ elif path == "/api/author-style/style-history":
+ fulfill_json(route, STYLE_DETAIL)
+ elif path == "/api/brief-sessions/session-history":
+ fulfill_json(route, SESSION_DETAIL)
+ elif path == "/api/projects/project-history":
+ fulfill_json(route, PROJECT_DETAIL)
+ elif path == "/api/articles/article-history":
+ fulfill_json(route, ARTICLE_DETAIL)
+ elif path == "/api/drafts/draft-history":
+ fulfill_json(route, DRAFT_DETAIL)
+ else:
+ pytest.fail(f"Unexpected unstubbed API request: {request.method} {path}")
+
+ page.route("**/api/**", api_handler)
+ return state
+
+
+def fulfill_json(route: Route, data: Any, status: int = 200) -> None:
+ route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(data, ensure_ascii=False),
+ )
+
+
+def fulfill_sse(route: Route, events: list[tuple[str, dict[str, Any]]]) -> None:
+ body = "".join(
+ f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+ for event, data in events
+ )
+ route.fulfill(status=200, content_type="text/event-stream", body=body)
+
+
+def hold_request(key: str):
+ def handler(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None:
+ state.pending_routes.setdefault(key, []).append(route)
+ return None
+
+ return handler
+
+
+def abort_pending(state: StubState, key: str) -> None:
+ for route in state.pending_routes.pop(key, []):
+ try:
+ route.abort("aborted")
+ except Exception:
+ pass
+
+
+def open_app(page: Page, base_url: str) -> None:
+ page.goto(base_url.rstrip("/") + "/")
+ expect(page.locator("#persona-select")).to_have_value("terisuke")
+ expect(page.locator("#history-status")).to_contain_text("1件のプロジェクト")
+
+
+def select_when_enabled(page: Page, selector: str, value: str) -> None:
+ locator = page.locator(selector)
+ expect(locator).to_be_enabled()
+ locator.select_option(value)
+
+
+def open_history_session(page: Page) -> None:
+ select_when_enabled(page, "#history-style-select", "style-history")
+ expect(page.locator("#style-guide-card")).to_contain_text("具体例から始める")
+ select_when_enabled(page, "#history-session-select", "session-history")
+ expect(page.locator("#brief-card")).to_contain_text("履歴から再開する記事")
+ page.locator("#open-history-btn").click()
+ expect(page.locator("#history-status")).to_contain_text("選択した履歴を現在の作業状態に反映しました")
+
+
+def open_history_draft(page: Page) -> None:
+ open_history_session(page)
+ select_when_enabled(page, "#history-draft-select", "draft-history")
+ expect(page.locator("#history-current-draft-card")).to_contain_text("履歴下書き")
+ page.locator("#open-history-btn").click()
+ expect(page.locator("#markdown-output")).to_have_value(re.compile("Target Section"))
+
+
+def test_history_opening_populates_all_selects_and_readable_cards(page: Page, base_url: str) -> None:
+ install_routes(page)
+ open_app(page, base_url)
+
+ select_when_enabled(page, "#history-style-select", "style-history")
+ expect(page.locator("#style-guide-card")).to_contain_text("履歴文体ガイド")
+ expect(page.locator("#style-guide-card")).to_contain_text("具体例から始める")
+
+ select_when_enabled(page, "#history-session-select", "session-history")
+ expect(page.locator("#brief-card")).to_contain_text("履歴から再開する記事")
+ expect(page.locator("#brief-card")).to_contain_text("保存済みプロジェクトを確認する編集者")
+
+ select_when_enabled(page, "#history-project-select", "project-history")
+ expect(page.locator("#history-project-card")).to_contain_text("履歴プロジェクト")
+ expect(page.locator("#history-project-card")).to_contain_text("1件")
+
+ select_when_enabled(page, "#history-article-select", "article-history")
+ expect(page.locator("#history-article-card")).to_contain_text("履歴記事")
+ expect(page.locator("#history-article-brief-card")).to_contain_text("朝のレビューで履歴を開く")
+ expect(page.locator("#history-source-snapshot-card")).to_contain_text("参考記事A")
+
+ select_when_enabled(page, "#history-draft-select", "draft-history")
+ expect(page.locator("#history-current-draft-card")).to_contain_text("Target Section")
+ expect(page.locator("#history-draft-versions-card")).to_contain_text("score 4.2")
+
+ page.locator("#open-history-btn").click()
+
+ expect(page.locator("#history-status")).to_contain_text("選択した履歴を現在の作業状態に反映しました")
+ expect(page.locator("#profile-id")).to_have_text("style-history")
+ expect(page.locator("#question-log")).to_contain_text("Original history answer")
+ expect(page.locator("#brief-card")).to_contain_text("途中から安全に再開する")
+ expect(page.locator("#markdown-output")).to_have_value(re.compile("古い対象セクションです。"))
+ expect(page.locator("#preview-content")).to_contain_text("履歴下書き")
+
+
+def test_answer_streaming_follow_up_and_cancel_recovery(page: Page, base_url: str) -> None:
+ def seed_style(route: Route, request: Request, call: dict[str, Any], state: StubState) -> dict[str, Any]:
+ return STYLE_DETAIL
+
+ def create_session(route: Route, request: Request, call: dict[str, Any], state: StubState) -> dict[str, Any]:
+ return {
+ "session_id": "session-stream",
+ "style_profile_id": "style-history",
+ "completed": False,
+ "questions": QUESTIONS,
+ "answers": [],
+ "next_question": QUESTIONS[0],
+ }
+
+ def answer_stream(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None:
+ assert call["headers"]["accept"] == "text/event-stream"
+ assert call["payload"]["content"] == "最初の回答です"
+ fulfill_sse(
+ route,
+ [
+ ("status", {"status": "stream_opened", "elapsed_ms": 0}),
+ ("chunk", {"text": "どの判断を", "elapsed_ms": 10}),
+ ("chunk", {"text": "最初に説明しますか?", "elapsed_ms": 20}),
+ (
+ "result",
+ {
+ "session_id": "session-stream",
+ "completed": False,
+ "answers": [
+ {
+ "question_id": "theme",
+ "content": "最初の回答です",
+ "flow_type": "main",
+ }
+ ],
+ "next_question": {
+ "id": "theme_follow_1",
+ "text": "どの判断を最初に説明しますか?",
+ "flow_type": "deep_dive_follow_up",
+ "target_question_id": "theme",
+ "follow_up_index": 1,
+ },
+ },
+ ),
+ ("done", {"status": "completed", "elapsed_ms": 30}),
+ ],
+ )
+
+ state = install_routes(
+ page,
+ {
+ "/api/author-style/seed": seed_style,
+ "/api/brief-sessions": create_session,
+ "/api/brief-sessions/session-stream/answers": answer_stream,
+ },
+ )
+ open_app(page, base_url)
+
+ page.locator("#use-preset-style-btn").click()
+ expect(page.locator("#profile-id")).to_have_text("style-history")
+ page.locator("#start-interview-btn").click()
+ expect(page.locator("#question-log")).to_contain_text("この記事で一番伝えたいことは?")
+
+ page.locator("#answer-input").fill("最初の回答です")
+ page.locator("#submit-answer-btn").click()
+
+ expect(page.locator("#question-log")).to_contain_text("どの判断を最初に説明しますか?")
+ expect(page.locator("#question-log")).to_contain_text("最初の回答です")
+ expect(page.locator("#cancel-answer-btn")).to_be_hidden()
+ expect(page.locator("#submit-answer-btn")).to_be_enabled()
+
+ page.unroute("**/api/**")
+ state = install_routes(
+ page,
+ {
+ "/api/author-style/seed": seed_style,
+ "/api/brief-sessions": create_session,
+ "/api/brief-sessions/session-stream/answers": hold_request("answer"),
+ },
+ )
+ page.reload()
+ expect(page.locator("#history-status")).to_contain_text("1件のプロジェクト")
+ page.locator("#use-preset-style-btn").click()
+ page.locator("#start-interview-btn").click()
+ expect(page.locator("#question-log")).to_contain_text("この記事で一番伝えたいことは?")
+
+ page.locator("#answer-input").fill("キャンセルする回答")
+ page.locator("#submit-answer-btn").evaluate("button => button.click()")
+ expect(page.locator("#cancel-answer-btn")).to_be_visible()
+ page.locator("#cancel-answer-btn").click()
+ abort_pending(state, "answer")
+
+ expect(page.locator("#cancel-answer-btn")).to_be_hidden()
+ expect(page.locator("#submit-answer-btn")).to_be_enabled()
+ expect(page.locator("#question-log")).to_contain_text("処理を停止しました")
+
+
+def test_draft_streaming_and_cancel_recovery(page: Page, base_url: str) -> None:
+ stream_markdown = "# Streaming Draft\n\n## Body\n\n途中まで生成してから完成します。\n"
+
+ def draft_stream(route: Route, request: Request, call: dict[str, Any], state: StubState) -> None:
+ assert call["headers"]["accept"] == "text/event-stream"
+ assert call["payload"]["style_profile_id"] == "style-history"
+ assert call["payload"]["session_id"] == "session-history"
+ fulfill_sse(
+ route,
+ [
+ ("status", {"status": "runtime_connected", "endpoint": "stubbed", "model": "draft-e2e", "elapsed_ms": 0}),
+ ("chunk", {"text": "# Streaming Draft\n\n", "elapsed_ms": 5}),
+ ("chunk", {"text": "## Body\n\n途中まで生成してから完成します。\n", "elapsed_ms": 15}),
+ (
+ "result",
+ {
+ "draft": stream_markdown,
+ "evaluation": {"passed": True, "comparison": {"score": 4.6}, "failures": []},
+ "verification": {"performed": True, "passed": True, "summary": "stub verified", "failures": []},
+ "quality_gate": {"score": 4.6, "runes": 28, "failed_metrics": []},
+ },
+ ),
+ ("done", {"status": "completed", "elapsed_ms": 40, "runes": 28, "score": 4.6}),
+ ],
+ )
+
+ install_routes(page, {"/api/drafts": draft_stream})
+ open_app(page, base_url)
+ open_history_session(page)
+
+ page.locator("#generate-draft-btn").click()
+
+ expect(page.locator("#markdown-output")).to_have_value(re.compile("途中まで生成してから完成します。"))
+ expect(page.locator("#evaluation-summary")).to_contain_text("PASS")
+ expect(page.locator("#verification-summary")).to_contain_text("stub verified")
+ expect(page.locator("#draft-status")).to_contain_text("score 4.6")
+ expect(page.locator("#cancel-draft-btn")).to_be_hidden()
+ expect(page.locator("#generate-draft-btn")).to_be_enabled()
+
+ page.unroute("**/api/**")
+ state = install_routes(page, {"/api/drafts": hold_request("draft")})
+ page.reload()
+ expect(page.locator("#history-status")).to_contain_text("1件のプロジェクト")
+ open_history_session(page)
+
+ page.locator("#generate-draft-btn").evaluate("button => button.click()")
+ expect(page.locator("#cancel-draft-btn")).to_be_visible()
+ page.locator("#cancel-draft-btn").click()
+ abort_pending(state, "draft")
+
+ expect(page.locator("#cancel-draft-btn")).to_be_hidden()
+ expect(page.locator("#generate-draft-btn")).to_be_enabled()
+ expect(page.locator("#draft-status")).to_contain_text("停止しました")
+
+
+def test_edit_answer_calls_fork_endpoint_and_updates_transcript(page: Page, base_url: str) -> None:
+ def fork_answer(route: Route, request: Request, call: dict[str, Any], state: StubState) -> dict[str, Any]:
+ assert call["method"] == "POST"
+ assert call["payload"]["content"] == "Forked answer with sharper direction"
+ return {
+ "session_id": "session-fork",
+ "parent_session_id": "session-history",
+ "completed": False,
+ "questions": QUESTIONS,
+ "answers": [
+ {
+ "question_id": "theme",
+ "content": "Forked answer with sharper direction",
+ "flow_type": "main",
+ }
+ ],
+ "next_question": QUESTIONS[1],
+ }
+
+ state = install_routes(
+ page,
+ {
+ "/api/brief-sessions/session-history/answers/theme/edit": fork_answer,
+ },
+ )
+ open_app(page, base_url)
+ open_history_session(page)
+
+ page.locator(".answer-edit-btn").first.click()
+ page.locator(".answer-edit-input").fill("Forked answer with sharper direction")
+ page.get_by_role("button", name="保存して分岐").click()
+
+ expect(page.locator("#question-log")).to_contain_text("Forked answer with sharper direction")
+ expect(page.locator("#question-log")).to_contain_text("想定読者は誰ですか?")
+ expect(page.locator("#question-log")).not_to_contain_text("Original history answer")
+ assert state.calls_to("/api/brief-sessions/session-history/answers/theme/edit")
+
+
+def test_section_regeneration_candidate_reject_and_accept_flow(page: Page, base_url: str) -> None:
+ replacements = [
+ "## Target Section\n\n破棄する候補です。\n",
+ "## Target Section\n\n採用した再生成候補です。\n",
+ ]
+
+ def regenerate(route: Route, request: Request, call: dict[str, Any], state: StubState) -> dict[str, Any]:
+ assert call["method"] == "POST"
+ assert call["payload"]["style_profile_id"] == "style-history"
+ assert call["payload"]["session_id"] == "session-history"
+ assert call["payload"]["section_anchor"] == "target-section"
+ assert "古い対象セクションです。" in call["payload"]["draft_markdown"]
+ replacement = replacements.pop(0)
+ return {
+ "draft_id": "draft-regenerated",
+ "section": {"anchor": "target-section", "heading": "Target Section"},
+ "replacement_markdown": replacement,
+ }
+
+ state = install_routes(
+ page,
+ {
+ "/api/drafts/session-history/regenerate-section": regenerate,
+ },
+ )
+ open_app(page, base_url)
+ open_history_draft(page)
+
+ page.get_by_role("button", name="Markdown").click()
+ markdown = page.locator("#markdown-output")
+ markdown.focus()
+ target_offset = DRAFT_MARKDOWN.index("古い対象セクション")
+ markdown.evaluate(
+ "(textarea, offset) => { textarea.setSelectionRange(offset, offset); textarea.dispatchEvent(new Event('click', { bubbles: true })); }",
+ target_offset,
+ )
+ expect(page.locator("#section-status")).to_contain_text("Target Section")
+ expect(page.locator("#regenerate-section-btn")).to_be_enabled()
+
+ page.locator("#regenerate-section-btn").click()
+ expect(page.locator("#section-candidate")).to_be_visible()
+ expect(page.locator("#section-candidate-output")).to_have_value("## Target Section\n\n破棄する候補です。\n")
+ page.locator("#reject-section-btn").click()
+ expect(page.locator("#section-candidate")).to_be_hidden()
+ expect(markdown).to_have_value(re.compile("古い対象セクションです。"))
+
+ page.locator("#regenerate-section-btn").click()
+ expect(page.locator("#section-candidate-output")).to_have_value("## Target Section\n\n採用した再生成候補です。\n")
+ page.locator("#accept-section-btn").click()
+
+ expect(page.locator("#section-candidate")).to_be_hidden()
+ expect(markdown).to_have_value(re.compile("採用した再生成候補です。"))
+ expect(markdown).not_to_have_value(re.compile("古い対象セクションです。"))
+ expect(page.locator("#preview-content")).to_contain_text("採用した再生成候補です。")
+ assert len(state.calls_to("/api/drafts/session-history/regenerate-section")) == 2
From 1b4afb8e2a95b9ae7cee8363c73f0d91f5e7fce5 Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 21:21:16 +0900
Subject: [PATCH 31/33] Polish Phase C persona history cards
Closes #27
Closes #28
---
cmd/server/main.go | 4 +
cmd/server/main_test.go | 4 +
...02-multi-persona-multi-format-extension.md | 40 +-
.../issue-27-28-history-artifacts-api.md | 20 +-
.../issue-adr-guardrails.md | 10 +-
.../next-implementation-cut.md | 58 +-
...13-browser-contract-coverage-2026-05-03.md | 16 +-
.../issue-13-browser-e2e-2026-05-03.md | 4 +-
...ssue-27-28-history-artifacts-2026-05-03.md | 14 +-
...-persona-history-card-polish-2026-05-03.md | 76 +++
.../runtime-ui-ddd-audit-2026-05-03.md | 9 +-
internal/domain/persona/persona.go | 33 +-
internal/handlers/workflow.go | 389 +++++++++++++-
internal/handlers/workflow_edit_test.go | 102 ++++
internal/handlers/workflow_persona_test.go | 96 ++++
.../repository/memory/workflow.go | 46 ++
.../repository/memory/workflow_test.go | 51 ++
.../migrations/0002_custom_personas.sql | 10 +
.../repository/sqlite/workflow.go | 71 +++
.../repository/sqlite/workflow_test.go | 55 +-
static/css/style.css | 84 +++
static/history_ui_test.go | 112 ++++
static/index.html | 51 +-
static/js/script.js | 504 +++++++++++++++++-
tests/e2e/test_history_stream_regenerate.py | 5 +-
.../test_phase_c_persona_history_polish.py | 242 +++++++++
26 files changed, 2022 insertions(+), 84 deletions(-)
create mode 100644 docs/validation/phase-c-persona-history-card-polish-2026-05-03.md
create mode 100644 internal/handlers/workflow_persona_test.go
create mode 100644 internal/infrastructure/repository/sqlite/migrations/0002_custom_personas.sql
create mode 100644 tests/e2e/test_phase_c_persona_history_polish.py
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 @@ 設定
-
+
書き手
-
+
+
+ + Add persona
+
出力先
@@ -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
From 4af05cadaeacc5fa10bdd22aa4d5992200c7d8ba Mon Sep 17 00:00:00 2001
From: Terada Kousuke
Date: Sun, 3 May 2026 21:43:46 +0900
Subject: [PATCH 32/33] Close product memory and launcher gaps
Closes #14. Closes #15. Updates #36. Updates #45.
---
Makefile | 58 +-
README.md | 67 +-
cmd/scenario/local_llamacpp_fallback/main.go | 709 ++++++++++++++++++
.../local_llamacpp_fallback/main_test.go | 117 +++
cmd/server/main.go | 3 +
cmd/server/main_test.go | 3 +
...01-three-phase-local-article-generation.md | 2 +-
...02-multi-persona-multi-format-extension.md | 3 +-
.../issue-adr-guardrails.md | 3 +
.../multi-persona-multi-format.md | 2 +-
.../issue-45-llamacpp-swap-orchestration.md | 190 +++++
.../issue-15-launcher-2026-05-03.md | 46 ++
...cal-llamacpp-fallback-status-2026-05-03.md | 95 +++
...sue-36-local-llamacpp-fallback-template.md | 85 +++
...-llamacpp-swap-orchestration-2026-05-03.md | 233 ++++++
internal/domain/brief/types.go | 9 +
internal/domain/persona/persona.go | 6 +
internal/handlers/workflow.go | 156 ++++
internal/handlers/workflow_edit_test.go | 26 +
internal/handlers/workflow_persona_test.go | 94 +++
.../repository/memory/workflow.go | 108 ++-
.../repository/memory/workflow_test.go | 45 +-
.../sqlite/migrations/0003_brief_versions.sql | 24 +
.../repository/sqlite/workflow.go | 119 ++-
.../repository/sqlite/workflow_test.go | 94 ++-
mise.toml | 18 +-
scripts/check-launcher.sh | 28 +
scripts/evo-x2-llama-swap.sh | 195 +++++
scripts/launcher.sh | 502 +++++++++++++
scripts/launcher_test.sh | 83 ++
static/css/style.css | 46 +-
static/history_ui_test.go | 48 +-
static/index.html | 4 +-
static/js/script.js | 291 ++++++-
.../test_phase_c_persona_history_polish.py | 104 +++
35 files changed, 3575 insertions(+), 41 deletions(-)
create mode 100644 cmd/scenario/local_llamacpp_fallback/main.go
create mode 100644 cmd/scenario/local_llamacpp_fallback/main_test.go
create mode 100644 docs/research/issue-45-llamacpp-swap-orchestration.md
create mode 100644 docs/validation/issue-15-launcher-2026-05-03.md
create mode 100644 docs/validation/issue-36-local-llamacpp-fallback-status-2026-05-03.md
create mode 100644 docs/validation/issue-36-local-llamacpp-fallback-template.md
create mode 100644 docs/validation/issue-45-llamacpp-swap-orchestration-2026-05-03.md
create mode 100644 internal/infrastructure/repository/sqlite/migrations/0003_brief_versions.sql
create mode 100755 scripts/check-launcher.sh
create mode 100755 scripts/evo-x2-llama-swap.sh
create mode 100755 scripts/launcher.sh
create mode 100755 scripts/launcher_test.sh
diff --git a/Makefile b/Makefile
index 059a486..f969b23 100644
--- a/Makefile
+++ b/Makefile
@@ -11,6 +11,10 @@ EVO_X2_SSH_HOST ?= evo-x2
EVO_X2_SSH_LOCAL_PORT ?= 21434
EVO_X2_OLLAMA_LLM_BASE_URL ?= http://$(EVO_X2_TAILNET_HOST)/v1
EVO_X2_LLAMA_CPP_LLM_BASE_URL ?= http://$(EVO_X2_TAILNET_HOST)/llama/v1
+EVO_X2_LLAMA_CPP_SERVICE ?= note-maker-llama-cpp.service
+EVO_X2_LLAMA_CPP_PROFILE ?= fallback-gemma-e2b
+EVO_X2_LLAMA_CPP_APPLY ?= 0
+EVO_X2_LLAMA_CPP_ALLOW_RESTART ?= 0
EVO_X2_LLM_BASE_URL ?= $(EVO_X2_OLLAMA_LLM_BASE_URL)
EVO_X2_SSH_LLM_BASE_URL ?= http://127.0.0.1:$(EVO_X2_SSH_LOCAL_PORT)/v1
LLM_RUNTIME ?= remote
@@ -25,6 +29,11 @@ EVO_X2_VERIFY_LLM_MODEL ?= gemma4:latest
FALLBACK_LLM_BASE_URL ?= http://127.0.0.1:8081/v1
LLM_FALLBACK_BASE_URLS ?= $(EVO_X2_LLAMA_CPP_LLM_BASE_URL),$(FALLBACK_LLM_BASE_URL)
EVO_X2_LLAMA_CPP_MODEL ?= gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_STYLE_MODEL ?= $(EVO_X2_LLAMA_CPP_MODEL)
+EVO_X2_LLAMA_CPP_BRIEF_MODEL ?= $(EVO_X2_LLAMA_CPP_MODEL)
+EVO_X2_LLAMA_CPP_ARTICLE_MODEL ?= $(EVO_X2_LLAMA_CPP_MODEL)
+EVO_X2_LLAMA_CPP_DRAFT_MODEL ?= $(EVO_X2_LLAMA_CPP_MODEL)
+EVO_X2_LLAMA_CPP_VERIFY_MODEL ?= $(EVO_X2_LLAMA_CPP_MODEL)
STYLE_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),gemma4:e2b
BRIEF_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),qwen3:30b-a3b
ARTICLE_LLM_FALLBACK_MODELS ?= $(EVO_X2_LLAMA_CPP_MODEL),gemma4:e2b
@@ -38,11 +47,33 @@ LLAMACPP_BASE_URL ?= $(LLM_BASE_URL)
LLAMACPP_MODEL ?= gemma4:31b
LLAMACPP_HF_REPO ?= ggml-org/gemma-4-31B-it-GGUF
LLAMACPP_HF_FILE ?= gemma-4-31B-it-Q4_K_M.gguf
+LOCAL_LLAMACPP_FALLBACK_BASE_URL ?= http://127.0.0.1:8081/v1
+LOCAL_LLAMACPP_FALLBACK_MODEL ?= qwen3:30b-a3b
+LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL ?= $(LOCAL_LLAMACPP_FALLBACK_MODEL)
+LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS ?=
+LOCAL_LLAMACPP_FALLBACK_OUTPUT_DIR ?= tmp/local_llamacpp_fallback
+LOCAL_LLAMACPP_FALLBACK_MIN_STYLE_SCORE ?= 82
+LOCAL_LLAMACPP_FALLBACK_MIN_KEYWORD_OVERLAP ?= 70
+LOCAL_LLAMACPP_FALLBACK_MIN_DRAFT_RUNES ?= 2800
+LOCAL_LLAMACPP_FALLBACK_TIMEOUT_SECONDS ?= 900
+LOCAL_LLAMACPP_FALLBACK_MAX_ATTEMPTS ?= 2
LLAMA_SERVER ?= llama-server
-.PHONY: app dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models scenario-evo-x2 scenario-media-matrix-live server llama check e2e
+.PHONY: app launcher launcher-local launcher-status launcher-check dev evo-x2 remote evo-x2-preflight evo-x2-models evo-x2-ssh-models evo-x2-llama-status evo-x2-llama-plan evo-x2-llama-start evo-x2-llama-swap evo-x2-llama-check scenario-evo-x2 scenario-evo-x2-llama-brief-draft scenario-local-llamacpp-fallback scenario-media-matrix-live server llama check e2e
-app: dev
+app: launcher
+
+launcher:
+ ./scripts/launcher.sh
+
+launcher-local:
+ NOTE_MAKER_START_LOCAL_LLM=1 ./scripts/launcher.sh --local-llm
+
+launcher-status:
+ ./scripts/launcher.sh --status
+
+launcher-check:
+ ./scripts/check-launcher.sh
dev:
./scripts/dev.sh
@@ -62,9 +93,32 @@ evo-x2-ssh-models:
EVO_X2_SSH_HOST="$(EVO_X2_SSH_HOST)" EVO_X2_SSH_LOCAL_PORT="$(EVO_X2_SSH_LOCAL_PORT)" EVO_X2_LLM_BASE_URL="$(EVO_X2_SSH_LLM_BASE_URL)" ./scripts/evo-x2-ssh-preflight.sh
curl -s "$(EVO_X2_SSH_LLM_BASE_URL)/models"
+evo-x2-llama-status:
+ EVO_X2_TAILNET_HOST="$(EVO_X2_TAILNET_HOST)" EVO_X2_SSH_HOST="$(EVO_X2_SSH_HOST)" EVO_X2_OLLAMA_LLM_BASE_URL="$(EVO_X2_OLLAMA_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_SERVICE="$(EVO_X2_LLAMA_CPP_SERVICE)" EVO_X2_LLAMA_CPP_PROFILE="$(EVO_X2_LLAMA_CPP_PROFILE)" EVO_X2_LLAMA_CPP_APPLY="$(EVO_X2_LLAMA_CPP_APPLY)" EVO_X2_LLAMA_CPP_ALLOW_RESTART="$(EVO_X2_LLAMA_CPP_ALLOW_RESTART)" ./scripts/evo-x2-llama-swap.sh inspect
+
+evo-x2-llama-plan:
+ EVO_X2_TAILNET_HOST="$(EVO_X2_TAILNET_HOST)" EVO_X2_SSH_HOST="$(EVO_X2_SSH_HOST)" EVO_X2_OLLAMA_LLM_BASE_URL="$(EVO_X2_OLLAMA_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_SERVICE="$(EVO_X2_LLAMA_CPP_SERVICE)" EVO_X2_LLAMA_CPP_PROFILE="$(EVO_X2_LLAMA_CPP_PROFILE)" EVO_X2_LLAMA_CPP_APPLY="$(EVO_X2_LLAMA_CPP_APPLY)" EVO_X2_LLAMA_CPP_ALLOW_RESTART="$(EVO_X2_LLAMA_CPP_ALLOW_RESTART)" ./scripts/evo-x2-llama-swap.sh plan
+
+evo-x2-llama-start:
+ EVO_X2_TAILNET_HOST="$(EVO_X2_TAILNET_HOST)" EVO_X2_SSH_HOST="$(EVO_X2_SSH_HOST)" EVO_X2_OLLAMA_LLM_BASE_URL="$(EVO_X2_OLLAMA_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_SERVICE="$(EVO_X2_LLAMA_CPP_SERVICE)" EVO_X2_LLAMA_CPP_PROFILE="$(EVO_X2_LLAMA_CPP_PROFILE)" EVO_X2_LLAMA_CPP_APPLY="$(EVO_X2_LLAMA_CPP_APPLY)" EVO_X2_LLAMA_CPP_ALLOW_RESTART="$(EVO_X2_LLAMA_CPP_ALLOW_RESTART)" ./scripts/evo-x2-llama-swap.sh start
+
+evo-x2-llama-swap:
+ EVO_X2_TAILNET_HOST="$(EVO_X2_TAILNET_HOST)" EVO_X2_SSH_HOST="$(EVO_X2_SSH_HOST)" EVO_X2_OLLAMA_LLM_BASE_URL="$(EVO_X2_OLLAMA_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" EVO_X2_LLAMA_CPP_SERVICE="$(EVO_X2_LLAMA_CPP_SERVICE)" EVO_X2_LLAMA_CPP_PROFILE="$(EVO_X2_LLAMA_CPP_PROFILE)" EVO_X2_LLAMA_CPP_APPLY="$(EVO_X2_LLAMA_CPP_APPLY)" EVO_X2_LLAMA_CPP_ALLOW_RESTART="$(EVO_X2_LLAMA_CPP_ALLOW_RESTART)" ./scripts/evo-x2-llama-swap.sh swap
+
+evo-x2-llama-check:
+ bash -n scripts/dev.sh scripts/evo-x2-tailnet-preflight.sh scripts/evo-x2-ssh-preflight.sh scripts/evo-x2-llama-swap.sh
+
scenario-evo-x2: evo-x2-preflight
RUN_NOTE_SCENARIO=1 RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_STREAM_DRAFT=1 LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS="$(LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS)" LLM_STREAM_IDLE_TIMEOUT_SECONDS="$(LLM_STREAM_IDLE_TIMEOUT_SECONDS)" LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=2800 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/full_workflow
+scenario-evo-x2-llama-brief-draft:
+ @if [ "$${RUN_EVO_X2_LLAMA_CPP_SCENARIO:-0}" != "1" ]; then echo "Set RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 to run the live Evo X2 llama.cpp brief/draft validation."; exit 2; fi
+ EVO_X2_TAILNET_HOST="$(EVO_X2_TAILNET_HOST)" EVO_X2_LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" ./scripts/evo-x2-tailnet-preflight.sh
+ RUN_NOTE_SCENARIO=1 RUN_LOCAL_LLM_SCENARIO=1 SCENARIO_STREAM_DRAFT=1 LLM_BASE_URL="$(EVO_X2_LLAMA_CPP_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLAMA_CPP_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_LLAMA_CPP_STYLE_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_LLAMA_CPP_BRIEF_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_LLAMA_CPP_ARTICLE_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_LLAMA_CPP_DRAFT_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_LLAMA_CPP_VERIFY_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS="$(LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS)" LLM_STREAM_IDLE_TIMEOUT_SECONDS="$(LLM_STREAM_IDLE_TIMEOUT_SECONDS)" LLM_FALLBACK_BASE_URLS="" STYLE_LLM_FALLBACK_MODELS="" BRIEF_LLM_FALLBACK_MODELS="" ARTICLE_LLM_FALLBACK_MODELS="" DRAFT_LLM_FALLBACK_MODELS="" VERIFY_LLM_FALLBACK_MODELS="" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=2800 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/full_workflow
+
+scenario-local-llamacpp-fallback:
+ LOCAL_LLAMACPP_FALLBACK_BASE_URL="$(LOCAL_LLAMACPP_FALLBACK_BASE_URL)" LOCAL_LLAMACPP_FALLBACK_MODEL="$(LOCAL_LLAMACPP_FALLBACK_MODEL)" LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL="$(LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL)" LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS="$(LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS)" LOCAL_LLAMACPP_FALLBACK_OUTPUT_DIR="$(LOCAL_LLAMACPP_FALLBACK_OUTPUT_DIR)" LOCAL_LLAMACPP_FALLBACK_MIN_STYLE_SCORE="$(LOCAL_LLAMACPP_FALLBACK_MIN_STYLE_SCORE)" LOCAL_LLAMACPP_FALLBACK_MIN_KEYWORD_OVERLAP="$(LOCAL_LLAMACPP_FALLBACK_MIN_KEYWORD_OVERLAP)" LOCAL_LLAMACPP_FALLBACK_MIN_DRAFT_RUNES="$(LOCAL_LLAMACPP_FALLBACK_MIN_DRAFT_RUNES)" LOCAL_LLAMACPP_FALLBACK_TIMEOUT_SECONDS="$(LOCAL_LLAMACPP_FALLBACK_TIMEOUT_SECONDS)" LOCAL_LLAMACPP_FALLBACK_MAX_ATTEMPTS="$(LOCAL_LLAMACPP_FALLBACK_MAX_ATTEMPTS)" go run ./cmd/scenario/local_llamacpp_fallback
+
scenario-media-matrix-live: evo-x2-preflight
SCENARIO_OUTPUT_DIR=tmp/media_matrix go run ./cmd/scenario/media_matrix
RUN_LIVE_MEDIA_MATRIX=1 SCENARIO_STREAM_DRAFT=1 SCENARIO_OUTPUT_DIR=tmp/media_matrix LIVE_MEDIA_MATRIX_OUTPUT_DIR=tmp/media_matrix/live LLM_BASE_URL="$(EVO_X2_LLM_BASE_URL)" LLM_MODEL="$(EVO_X2_LLM_MODEL)" STYLE_LLM_MODEL="$(EVO_X2_STYLE_LLM_MODEL)" BRIEF_LLM_MODEL="$(EVO_X2_BRIEF_LLM_MODEL)" ARTICLE_LLM_MODEL="$(EVO_X2_ARTICLE_LLM_MODEL)" DRAFT_LLM_MODEL="$(EVO_X2_DRAFT_LLM_MODEL)" VERIFY_LLM_MODEL="$(EVO_X2_VERIFY_LLM_MODEL)" LLM_TIMEOUT_SECONDS=900 LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS="$(LLM_STREAM_FIRST_BYTE_TIMEOUT_SECONDS)" LLM_STREAM_IDLE_TIMEOUT_SECONDS="$(LLM_STREAM_IDLE_TIMEOUT_SECONDS)" LLM_FALLBACK_BASE_URLS="$(LLM_FALLBACK_BASE_URLS)" STYLE_LLM_FALLBACK_MODELS="$(STYLE_LLM_FALLBACK_MODELS)" BRIEF_LLM_FALLBACK_MODELS="$(BRIEF_LLM_FALLBACK_MODELS)" ARTICLE_LLM_FALLBACK_MODELS="$(ARTICLE_LLM_FALLBACK_MODELS)" DRAFT_LLM_FALLBACK_MODELS="$(DRAFT_LLM_FALLBACK_MODELS)" VERIFY_LLM_FALLBACK_MODELS="$(VERIFY_LLM_FALLBACK_MODELS)" SCENARIO_MIN_STYLE_SCORE=80 SCENARIO_MIN_DRAFT_RUNES=1400 DRAFT_MAX_ATTEMPTS=2 go run ./cmd/scenario/live_media_matrix
diff --git a/README.md b/README.md
index c147d2f..d47e585 100644
--- a/README.md
+++ b/README.md
@@ -48,18 +48,55 @@ LLAMACPP_BASE_URL=http://127.0.0.1:8081/v1
LLAMACPP_MODEL=gemma4:31b
```
-### まとめて起動する
+### アプリ風 launcher で起動する
-`llama-server` と Go サーバーをまとめて起動できます。
+通常利用は launcher 経由を推奨します。launcher は空いているローカルポートを選び、Evo X2 Tailnet の primary LLM health を確認し、Go サーバーをビルドして起動し、終了時に子プロセスを停止します。既定では Mac 側のローカル fallback LLM は起動しません。
```bash
-make app
+make launcher
```
-ブラウザで `http://localhost:8080` にアクセスします。終了するときは `Ctrl-C` で両方のプロセスを停止できます。
+または mise を使う場合:
+
+```bash
+mise run launcher
+```
+
+`make app` も同じ launcher を起動する alias です。
+
+起動後はブラウザが自動で開きます。終了するときは launcher を実行したターミナルで `Ctrl-C` を押します。`PORT` が使用中の場合は、既定で次の空きポートを選びます。固定ポートで失敗させたい場合は `./scripts/launcher.sh --strict-port` を使います。
+
+launcher の既定保存先:
+
+- macOS: `~/Library/Application Support/Note Maker`
+- Linux/その他: `$XDG_DATA_HOME/note-maker` または `~/.local/share/note-maker`
+
+この配下に `app_config.json`、`workflow_store.json`、`logs/`、ビルド済みサーバーバイナリを置きます。保存先を変える場合は `NOTE_MAKER_DATA_DIR=/path/to/dir make launcher` または `./scripts/launcher.sh --data-dir /path/to/dir` を指定します。
+
+Evo X2 Tailnet が到達不能な場合、既定ではアプリを起動しません。UIだけを起動したい検証時は `make launcher-status` で状態を確認し、必要に応じて `./scripts/launcher.sh --allow-degraded` を使います。ローカル `llama-server` を明示的に起動して primary として使う場合だけ、次を実行します。
+
+```bash
+make launcher-local
+```
`llama-server` の場所やモデルを変える場合は `.env` の `LLAMA_SERVER`、`LLAMACPP_HF_REPO`、`LLAMACPP_HF_FILE`、`LLAMACPP_MODEL` を変更します。
+launcher 自体の検証:
+
+```bash
+make launcher-check
+```
+
+### 旧 dev script でまとめて起動する
+
+従来の `scripts/dev.sh` は残しています。`LLM_RUNTIME=local` を明示した検証では `llama-server` と Go サーバーをまとめて起動できます。
+
+```bash
+make dev
+```
+
+ブラウザで `http://localhost:8080` にアクセスします。終了するときは `Ctrl-C` で両方のプロセスを停止できます。
+
### Evo X2 の Ollama を Tailscale VPN 経由で使って起動する
Evo X2 の Ollama を使う場合は、Tailscale VPN/MagicDNS 上の OpenAI互換APIを primary とし、Mac側のローカルLLMは起動しません。fallback は順番を固定します。
@@ -123,6 +160,28 @@ make scenario-evo-x2
このシナリオは文体分析、一問一答、深掘り、下書き生成を通し、文体スコア80点以上と一定以上の本文量を確認します。
+### ローカル llama.cpp fallback だけを検証する
+
+Evo X2 primary とは別に、作業端末上で既に起動済みの `llama.cpp` fallback だけを検証する場合は専用ターゲットを使います。このターゲットは Ollama や `llama-server` を起動せず、Evo X2 への fallback chain も無効化します。
+
+まず plan/report だけを作る場合:
+
+```bash
+make scenario-local-llamacpp-fallback
+```
+
+実際に draft 生成まで走らせる場合は、誤って Evo X2 primary 検証と混同しないように明示的な gate が必要です。`LOCAL_LLAMACPP_FALLBACK_BASE_URL` は loopback の OpenAI互換 `/v1` endpoint だけを受け付けます。
+
+```bash
+RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1 \
+LOCAL_LLAMACPP_FALLBACK_BASE_URL=http://127.0.0.1:8081/v1 \
+LOCAL_LLAMACPP_FALLBACK_MODEL=qwen3:30b-a3b \
+LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS='--host 127.0.0.1 --port 8081 --alias qwen3:30b-a3b --reasoning off ...' \
+make scenario-local-llamacpp-fallback
+```
+
+結果は `tmp/local_llamacpp_fallback/report.md` と `tmp/local_llamacpp_fallback/report.json` に記録されます。Issue #36 の合格条件は `score >= 82.0`、`keyword_overlap >= 70`、`runes >= 2800` です。記録テンプレートは `docs/validation/issue-36-local-llamacpp-fallback-template.md` です。
+
### 個別に起動する
Gemma4 31B を `llama-server` で起動します。
diff --git a/cmd/scenario/local_llamacpp_fallback/main.go b/cmd/scenario/local_llamacpp_fallback/main.go
new file mode 100644
index 0000000..336e7dc
--- /dev/null
+++ b/cmd/scenario/local_llamacpp_fallback/main.go
@@ -0,0 +1,709 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/teradakousuke/note_maker/internal/infrastructure/llamacpp"
+)
+
+const (
+ defaultOutputDir = "tmp/local_llamacpp_fallback"
+ defaultBaseURL = "http://127.0.0.1:8081/v1"
+ defaultModel = "qwen3:30b-a3b"
+)
+
+func main() {
+ started := time.Now()
+ config, err := scenarioConfigFromEnv()
+ if err != nil {
+ fatalf("%v", err)
+ }
+ if err := os.MkdirAll(config.OutputDir, 0o755); err != nil {
+ fatalf("create output dir: %v", err)
+ }
+
+ report := newPlanReport(config)
+ endpoint := checkEndpoint(config)
+ report.Endpoint = endpoint
+ report.TotalElapsedSeconds = time.Since(started).Seconds()
+
+ if !config.ExplicitRun {
+ report.Status = "plan_only"
+ report.Remaining = planRemaining(endpoint)
+ writeReports(config.OutputDir, report)
+ printPlan(report)
+ return
+ }
+ if !endpoint.Available {
+ report.Status = "plan_only"
+ report.Remaining = []string{"Start or expose an already-approved local llama.cpp endpoint on the recorded loopback base URL, then rerun with RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1. This runner does not start llama-server or any other local model process."}
+ writeReports(config.OutputDir, report)
+ printPlan(report)
+ return
+ }
+
+ report.Status = "running"
+ writeReports(config.OutputDir, report)
+ report.Commands = append(report.Commands, runStep(config, "author style", []string{
+ "SCENARIO_OUTPUT_DIR=" + filepath.Join(config.OutputDir, "author_style"),
+ }, "go", "run", "./cmd/scenario/author_style"))
+ if lastCommandFailed(report.Commands) {
+ finishWithFailure(config.OutputDir, started, report)
+ }
+
+ profileID := readProfileID(filepath.Join(config.OutputDir, "author_style", "profile.json"))
+ report.Commands = append(report.Commands, runStep(config, "brief interview", []string{
+ "SCENARIO_OUTPUT_DIR=" + filepath.Join(config.OutputDir, "brief_interview"),
+ "STYLE_PROFILE_ID=" + profileID,
+ }, "go", "run", "./cmd/scenario/brief_interview"))
+ if lastCommandFailed(report.Commands) {
+ finishWithFailure(config.OutputDir, started, report)
+ }
+
+ draftCommand := runStep(config, "draft generation", []string{
+ "RUN_LOCAL_LLM_SCENARIO=1",
+ "SCENARIO_OUTPUT_DIR=" + filepath.Join(config.OutputDir, "draft_generation"),
+ "AUTHOR_PROFILE_PATH=" + filepath.Join(config.OutputDir, "author_style", "profile.json"),
+ "WRITING_GUIDE_PATH=" + filepath.Join(config.OutputDir, "author_style", "guide.json"),
+ "ARTICLE_BRIEF_PATH=" + filepath.Join(config.OutputDir, "brief_interview", "brief.json"),
+ }, "go", "run", "./cmd/scenario/draft_generation")
+ report.Commands = append(report.Commands, draftCommand)
+ report.Result = resultFromDraftOutput(draftCommand.Stdout)
+ enrichResultFromEvaluation(config, &report)
+ report.Artifacts = scenarioArtifacts(config)
+ report.TotalElapsedSeconds = time.Since(started).Seconds()
+
+ if draftCommand.ExitCode != 0 {
+ report.Status = "failed"
+ report.Remaining = []string{"The dedicated local llama.cpp fallback run executed but did not meet the recorded thresholds. Inspect draft_generation artifacts before rerunning with a different model or llama-server load flags."}
+ writeReports(config.OutputDir, report)
+ fatalf("local llama.cpp fallback validation failed; report=%s", filepath.Join(config.OutputDir, "report.md"))
+ }
+ if !localFallbackPassed(report) {
+ report.Status = "failed"
+ report.Remaining = []string{"The local llama.cpp fallback response did not meet the recorded pass/fail thresholds."}
+ writeReports(config.OutputDir, report)
+ fatalf("local llama.cpp fallback validation did not pass thresholds; report=%s", filepath.Join(config.OutputDir, "report.md"))
+ }
+
+ report.Status = "passed"
+ report.Remaining = nil
+ writeReports(config.OutputDir, report)
+ fmt.Printf("local llama.cpp fallback validation completed\n")
+ fmt.Printf("status=%s\n", report.Status)
+ fmt.Printf("base_url=%s\n", report.Runtime.BaseURL)
+ fmt.Printf("model=%s\n", report.Runtime.Model)
+ fmt.Printf("elapsed_seconds=%.2f\n", report.Result.ElapsedSeconds)
+ fmt.Printf("score=%.1f\n", report.Result.Score)
+ fmt.Printf("keyword_overlap=%d\n", report.Result.KeywordOverlap)
+ fmt.Printf("runes=%d\n", report.Result.Runes)
+ fmt.Printf("min_style_score=%.1f\n", report.Thresholds.MinStyleScore)
+ fmt.Printf("min_keyword_overlap=%d\n", report.Thresholds.MinKeywordOverlap)
+ fmt.Printf("min_draft_runes=%d\n", report.Thresholds.MinDraftRunes)
+ fmt.Printf("load_flags=%s\n", report.Runtime.LoadFlags)
+ fmt.Printf("report=%s\n", filepath.Join(config.OutputDir, "report.md"))
+}
+
+type scenarioConfig struct {
+ OutputDir string
+ BaseURL string
+ Model string
+ VerifyModel string
+ LoadFlags string
+ MinStyleScore float64
+ MinKeywordOverlap int
+ MinDraftRunes int
+ MaxAttempts int
+ TimeoutSeconds int
+ StreamDraft bool
+ ExplicitRun bool
+}
+
+type scenarioReport struct {
+ GeneratedBy string `json:"generated_by"`
+ GeneratedAt string `json:"generated_at"`
+ Status string `json:"status"`
+ ExplicitRun bool `json:"explicit_run"`
+ Runtime runtimeReport `json:"runtime"`
+ Thresholds thresholdReport `json:"thresholds"`
+ Endpoint endpointReport `json:"endpoint"`
+ Result resultReport `json:"result,omitempty"`
+ Commands []commandReport `json:"commands,omitempty"`
+ Artifacts artifactReport `json:"artifacts,omitempty"`
+ Remaining []string `json:"remaining,omitempty"`
+ TotalElapsedSeconds float64 `json:"total_elapsed_seconds"`
+}
+
+type runtimeReport struct {
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ VerifyModel string `json:"verify_model"`
+ LoadFlags string `json:"load_flags"`
+ LocalFallbackOnly bool `json:"local_fallback_only"`
+ FallbackChainDisabled bool `json:"fallback_chain_disabled"`
+ StreamDraft bool `json:"stream_draft"`
+ TimeoutSeconds int `json:"timeout_seconds"`
+ MaxAttempts int `json:"max_attempts"`
+}
+
+type thresholdReport struct {
+ MinStyleScore float64 `json:"min_style_score"`
+ MinKeywordOverlap int `json:"min_keyword_overlap"`
+ MinDraftRunes int `json:"min_draft_runes"`
+}
+
+type endpointReport struct {
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ Checked bool `json:"checked"`
+ CheckedAt string `json:"checked_at"`
+ Available bool `json:"available"`
+ Models []string `json:"models,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type resultReport struct {
+ ScenarioPassed bool `json:"scenario_passed"`
+ Attempt int `json:"attempt"`
+ SelectedAttempt int `json:"selected_attempt"`
+ Passed bool `json:"passed"`
+ Score float64 `json:"score"`
+ KeywordOverlap int `json:"keyword_overlap"`
+ Runes int `json:"runes"`
+ VerificationPerformed bool `json:"verification_performed"`
+ VerificationPassed bool `json:"verification_passed"`
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ Streaming bool `json:"streaming"`
+ FirstChunkMs int `json:"first_chunk_ms,omitempty"`
+ Chunks int `json:"chunks,omitempty"`
+}
+
+type commandReport struct {
+ Label string `json:"label"`
+ Command string `json:"command"`
+ ExitCode int `json:"exit_code"`
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ StdoutPath string `json:"stdout_path"`
+ StderrPath string `json:"stderr_path"`
+ Stdout string `json:"-"`
+}
+
+type artifactReport struct {
+ Report string `json:"report"`
+ JSON string `json:"json"`
+ Draft string `json:"draft,omitempty"`
+ Evaluation string `json:"evaluation,omitempty"`
+ Verification string `json:"verification,omitempty"`
+}
+
+func scenarioConfigFromEnv() (scenarioConfig, error) {
+ config := scenarioConfig{
+ OutputDir: envOrDefault("LOCAL_LLAMACPP_FALLBACK_OUTPUT_DIR", envOrDefault("SCENARIO_OUTPUT_DIR", defaultOutputDir)),
+ BaseURL: envOrDefault("LOCAL_LLAMACPP_FALLBACK_BASE_URL", defaultBaseURL),
+ Model: envOrDefault("LOCAL_LLAMACPP_FALLBACK_MODEL", defaultModel),
+ VerifyModel: envOrDefault("LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL", envOrDefault("LOCAL_LLAMACPP_FALLBACK_MODEL", defaultModel)),
+ LoadFlags: envOrDefault("LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS", envOrDefault("LLAMACPP_LOAD_FLAGS", "")),
+ MinStyleScore: envFloat("LOCAL_LLAMACPP_FALLBACK_MIN_STYLE_SCORE", envFloat("SCENARIO_MIN_STYLE_SCORE", 82)),
+ MinKeywordOverlap: envInt("LOCAL_LLAMACPP_FALLBACK_MIN_KEYWORD_OVERLAP", 70),
+ MinDraftRunes: envInt("LOCAL_LLAMACPP_FALLBACK_MIN_DRAFT_RUNES", envInt("SCENARIO_MIN_DRAFT_RUNES", 2800)),
+ MaxAttempts: envInt("LOCAL_LLAMACPP_FALLBACK_MAX_ATTEMPTS", envInt("DRAFT_MAX_ATTEMPTS", 2)),
+ TimeoutSeconds: envInt("LOCAL_LLAMACPP_FALLBACK_TIMEOUT_SECONDS", envInt("LLM_TIMEOUT_SECONDS", 900)),
+ StreamDraft: envOrDefault("LOCAL_LLAMACPP_FALLBACK_STREAM_DRAFT", "1") == "1",
+ ExplicitRun: os.Getenv("RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO") == "1",
+ }
+ if err := requireLoopbackBaseURL(config.BaseURL); err != nil {
+ return scenarioConfig{}, err
+ }
+ return config, nil
+}
+
+func requireLoopbackBaseURL(raw string) error {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return fmt.Errorf("parse LOCAL_LLAMACPP_FALLBACK_BASE_URL: %w", err)
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return fmt.Errorf("LOCAL_LLAMACPP_FALLBACK_BASE_URL must use http or https")
+ }
+ host := parsed.Hostname()
+ if host == "" {
+ return fmt.Errorf("LOCAL_LLAMACPP_FALLBACK_BASE_URL must include a host")
+ }
+ if strings.Contains(strings.ToLower(host), "evo-x2") {
+ return fmt.Errorf("LOCAL_LLAMACPP_FALLBACK_BASE_URL must be local fallback only, got Evo X2 host %q", host)
+ }
+ if host == "localhost" {
+ return nil
+ }
+ ip := net.ParseIP(host)
+ if ip != nil && ip.IsLoopback() {
+ return nil
+ }
+ return fmt.Errorf("LOCAL_LLAMACPP_FALLBACK_BASE_URL must be a loopback llama.cpp endpoint, got %q", host)
+}
+
+func newPlanReport(config scenarioConfig) scenarioReport {
+ return scenarioReport{
+ GeneratedBy: "cmd/scenario/local_llamacpp_fallback",
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Status: "plan_only",
+ ExplicitRun: config.ExplicitRun,
+ Runtime: runtimeReport{
+ BaseURL: config.BaseURL,
+ Model: config.Model,
+ VerifyModel: config.VerifyModel,
+ LoadFlags: config.LoadFlags,
+ LocalFallbackOnly: true,
+ FallbackChainDisabled: true,
+ StreamDraft: config.StreamDraft,
+ TimeoutSeconds: config.TimeoutSeconds,
+ MaxAttempts: config.MaxAttempts,
+ },
+ Thresholds: thresholdReport{
+ MinStyleScore: config.MinStyleScore,
+ MinKeywordOverlap: config.MinKeywordOverlap,
+ MinDraftRunes: config.MinDraftRunes,
+ },
+ Artifacts: scenarioArtifacts(config),
+ }
+}
+
+func checkEndpoint(config scenarioConfig) endpointReport {
+ report := endpointReport{
+ BaseURL: config.BaseURL,
+ Model: config.Model,
+ Checked: true,
+ CheckedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ client, err := llamacpp.NewClient(config.BaseURL, config.Model, nil)
+ if err != nil {
+ report.Error = err.Error()
+ return report
+ }
+ models, err := client.ListModels(ctx)
+ if err != nil {
+ report.Error = err.Error()
+ return report
+ }
+ report.Available = true
+ report.Models = models
+ return report
+}
+
+func runStep(config scenarioConfig, label string, extraEnv []string, name string, args ...string) commandReport {
+ fmt.Printf("running %s...\n", label)
+ started := time.Now()
+ command := exec.Command(name, args...)
+ command.Env = commandEnv(config, extraEnv)
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ command.Stdout = &stdout
+ command.Stderr = &stderr
+ err := command.Run()
+ exitCode := 0
+ if err != nil {
+ exitCode = 1
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ exitCode = exitErr.ExitCode()
+ }
+ }
+ stdoutPath := filepath.Join(config.OutputDir, "logs", sanitizeLabel(label)+".stdout")
+ stderrPath := filepath.Join(config.OutputDir, "logs", sanitizeLabel(label)+".stderr")
+ mustWriteFile(stdoutPath, stdout.String())
+ mustWriteFile(stderrPath, stderr.String())
+ if stdout.Len() > 0 {
+ fmt.Print(stdout.String())
+ }
+ if stderr.Len() > 0 {
+ fmt.Fprint(os.Stderr, stderr.String())
+ }
+ return commandReport{
+ Label: label,
+ Command: strings.Join(append([]string{name}, args...), " "),
+ ExitCode: exitCode,
+ ElapsedSeconds: time.Since(started).Seconds(),
+ StdoutPath: stdoutPath,
+ StderrPath: stderrPath,
+ Stdout: stdout.String(),
+ }
+}
+
+func commandEnv(config scenarioConfig, extra []string) []string {
+ values := map[string]string{}
+ for _, entry := range os.Environ() {
+ key, value, ok := strings.Cut(entry, "=")
+ if ok {
+ values[key] = value
+ }
+ }
+ overrides := []string{
+ "NOTE_MAKER_SKIP_ENV=1",
+ "LLM_BASE_URL=" + config.BaseURL,
+ "LLAMACPP_BASE_URL=" + config.BaseURL,
+ "LLM_MODEL=" + config.Model,
+ "STYLE_LLM_MODEL=" + config.Model,
+ "BRIEF_LLM_MODEL=" + config.Model,
+ "ARTICLE_LLM_MODEL=" + config.Model,
+ "DRAFT_LLM_MODEL=" + config.Model,
+ "VERIFY_LLM_MODEL=" + config.VerifyModel,
+ "LLM_TIMEOUT_SECONDS=" + strconv.Itoa(config.TimeoutSeconds),
+ "SCENARIO_DRAFT_TIMEOUT_SECONDS=" + strconv.Itoa(config.TimeoutSeconds),
+ "SCENARIO_MIN_STYLE_SCORE=" + fmt.Sprintf("%.1f", config.MinStyleScore),
+ "SCENARIO_MIN_DRAFT_RUNES=" + strconv.Itoa(config.MinDraftRunes),
+ "DRAFT_MAX_ATTEMPTS=" + strconv.Itoa(config.MaxAttempts),
+ "SCENARIO_STREAM_DRAFT=" + boolEnv(config.StreamDraft),
+ "LLM_FALLBACK_BASE_URLS=",
+ "FALLBACK_LLM_BASE_URLS=",
+ "FALLBACK_LLM_BASE_URL=",
+ "FALLBACK_LLAMACPP_BASE_URL=",
+ "STYLE_LLM_FALLBACK_BASE_URLS=",
+ "STYLE_FALLBACK_LLM_BASE_URLS=",
+ "STYLE_FALLBACK_LLM_BASE_URL=",
+ "BRIEF_LLM_FALLBACK_BASE_URLS=",
+ "BRIEF_FALLBACK_LLM_BASE_URLS=",
+ "BRIEF_FALLBACK_LLM_BASE_URL=",
+ "ARTICLE_LLM_FALLBACK_BASE_URLS=",
+ "ARTICLE_FALLBACK_LLM_BASE_URLS=",
+ "ARTICLE_FALLBACK_LLM_BASE_URL=",
+ "DRAFT_LLM_FALLBACK_BASE_URLS=",
+ "DRAFT_FALLBACK_LLM_BASE_URLS=",
+ "DRAFT_FALLBACK_LLM_BASE_URL=",
+ "VERIFY_LLM_FALLBACK_BASE_URLS=",
+ "VERIFY_FALLBACK_LLM_BASE_URLS=",
+ "VERIFY_FALLBACK_LLM_BASE_URL=",
+ "STYLE_LLM_FALLBACK_MODELS=",
+ "STYLE_FALLBACK_LLM_MODELS=",
+ "STYLE_FALLBACK_LLM_MODEL=",
+ "BRIEF_LLM_FALLBACK_MODELS=",
+ "BRIEF_FALLBACK_LLM_MODELS=",
+ "BRIEF_FALLBACK_LLM_MODEL=",
+ "ARTICLE_LLM_FALLBACK_MODELS=",
+ "ARTICLE_FALLBACK_LLM_MODELS=",
+ "ARTICLE_FALLBACK_LLM_MODEL=",
+ "DRAFT_LLM_FALLBACK_MODELS=",
+ "DRAFT_FALLBACK_LLM_MODELS=",
+ "DRAFT_FALLBACK_LLM_MODEL=",
+ "VERIFY_LLM_FALLBACK_MODELS=",
+ "VERIFY_FALLBACK_LLM_MODELS=",
+ "VERIFY_FALLBACK_LLM_MODEL=",
+ }
+ overrides = append(overrides, extra...)
+ for _, entry := range overrides {
+ key, value, _ := strings.Cut(entry, "=")
+ values[key] = value
+ }
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ env := make([]string, 0, len(keys))
+ for _, key := range keys {
+ env = append(env, key+"="+values[key])
+ }
+ return env
+}
+
+func resultFromDraftOutput(output string) resultReport {
+ values := keyValueLines(output)
+ return resultReport{
+ ScenarioPassed: parseBool(values["scenario_passed"]),
+ Attempt: parseInt(values["attempt"]),
+ SelectedAttempt: parseInt(values["selected_attempt"]),
+ Passed: parseBool(values["passed"]),
+ Score: parseFloat(values["score"]),
+ KeywordOverlap: parseInt(values["keyword_overlap"]),
+ Runes: parseInt(values["runes"]),
+ VerificationPerformed: parseBool(values["verification_performed"]),
+ VerificationPassed: parseBool(values["verification_passed"]),
+ ElapsedSeconds: parseFloat(values["elapsed_seconds"]),
+ Streaming: parseBool(values["streaming"]),
+ FirstChunkMs: parseInt(values["first_chunk_ms"]),
+ Chunks: parseInt(values["chunks"]),
+ }
+}
+
+func enrichResultFromEvaluation(config scenarioConfig, report *scenarioReport) {
+ evaluationPath := filepath.Join(config.OutputDir, "draft_generation", "evaluation.json")
+ encoded, err := os.ReadFile(evaluationPath)
+ if err != nil {
+ return
+ }
+ var evaluation struct {
+ Comparison struct {
+ MetricScores map[string]int `json:"metric_scores"`
+ } `json:"comparison"`
+ }
+ if err := json.Unmarshal(encoded, &evaluation); err != nil {
+ return
+ }
+ report.Result.KeywordOverlap = evaluation.Comparison.MetricScores["keyword_overlap"]
+}
+
+func localFallbackPassed(report scenarioReport) bool {
+ return report.Result.ScenarioPassed &&
+ report.Result.Score >= report.Thresholds.MinStyleScore &&
+ report.Result.KeywordOverlap >= report.Thresholds.MinKeywordOverlap &&
+ report.Result.Runes >= report.Thresholds.MinDraftRunes &&
+ (!report.Result.VerificationPerformed || report.Result.VerificationPassed)
+}
+
+func keyValueLines(output string) map[string]string {
+ values := map[string]string{}
+ for _, line := range strings.Split(output, "\n") {
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ values[key] = strings.TrimSpace(value)
+ }
+ return values
+}
+
+func readProfileID(path string) string {
+ encoded, err := os.ReadFile(path)
+ if err != nil {
+ fatalf("read profile: %v", err)
+ }
+ var payload struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(encoded, &payload); err != nil {
+ fatalf("decode profile: %v", err)
+ }
+ if strings.TrimSpace(payload.ID) == "" {
+ fatalf("profile id was empty")
+ }
+ return payload.ID
+}
+
+func lastCommandFailed(commands []commandReport) bool {
+ if len(commands) == 0 {
+ return false
+ }
+ return commands[len(commands)-1].ExitCode != 0
+}
+
+func finishWithFailure(outputDir string, started time.Time, report scenarioReport) {
+ report.Status = "failed"
+ report.TotalElapsedSeconds = time.Since(started).Seconds()
+ report.Remaining = []string{"A setup step failed before draft generation. Inspect the command stdout/stderr files recorded in the report."}
+ writeReports(outputDir, report)
+ fatalf("local llama.cpp fallback scenario setup failed; report=%s", filepath.Join(outputDir, "report.md"))
+}
+
+func planRemaining(endpoint endpointReport) []string {
+ remaining := []string{"Set RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1 before running live validation."}
+ if !endpoint.Available {
+ remaining = append(remaining, "Expose an already-approved local llama.cpp OpenAI-compatible /v1 endpoint on the recorded loopback base URL. This runner does not start llama-server or Ollama.")
+ }
+ return remaining
+}
+
+func writeReports(outputDir string, report scenarioReport) {
+ report.Artifacts.Report = filepath.Join(outputDir, "report.md")
+ report.Artifacts.JSON = filepath.Join(outputDir, "report.json")
+ mustWriteJSON(filepath.Join(outputDir, "report.json"), report)
+ mustWriteFile(filepath.Join(outputDir, "report.md"), markdownReport(report))
+}
+
+func scenarioArtifacts(config scenarioConfig) artifactReport {
+ draftDir := filepath.Join(config.OutputDir, "draft_generation")
+ return artifactReport{
+ Report: filepath.Join(config.OutputDir, "report.md"),
+ JSON: filepath.Join(config.OutputDir, "report.json"),
+ Draft: filepath.Join(draftDir, "draft.md"),
+ Evaluation: filepath.Join(draftDir, "evaluation.json"),
+ Verification: filepath.Join(draftDir, "verification.json"),
+ }
+}
+
+func markdownReport(report scenarioReport) string {
+ var builder strings.Builder
+ builder.WriteString("# Local llama.cpp fallback validation\n\n")
+ builder.WriteString(fmt.Sprintf("- Generated by: `%s`\n", report.GeneratedBy))
+ builder.WriteString(fmt.Sprintf("- Generated at: `%s`\n", report.GeneratedAt))
+ builder.WriteString(fmt.Sprintf("- Status: `%s`\n", report.Status))
+ builder.WriteString(fmt.Sprintf("- Explicit run: `%v`\n", report.ExplicitRun))
+ builder.WriteString("\n## Runtime\n\n")
+ builder.WriteString(fmt.Sprintf("- Base URL: `%s`\n", report.Runtime.BaseURL))
+ builder.WriteString(fmt.Sprintf("- Model: `%s`\n", report.Runtime.Model))
+ builder.WriteString(fmt.Sprintf("- Verify model: `%s`\n", report.Runtime.VerifyModel))
+ builder.WriteString(fmt.Sprintf("- Load flags: `%s`\n", valueOrNone(report.Runtime.LoadFlags)))
+ builder.WriteString(fmt.Sprintf("- Local fallback only: `%v`\n", report.Runtime.LocalFallbackOnly))
+ builder.WriteString(fmt.Sprintf("- Fallback chain disabled: `%v`\n", report.Runtime.FallbackChainDisabled))
+ builder.WriteString(fmt.Sprintf("- Streaming: `%v`\n", report.Runtime.StreamDraft))
+ builder.WriteString(fmt.Sprintf("- Timeout seconds: `%d`\n", report.Runtime.TimeoutSeconds))
+ builder.WriteString(fmt.Sprintf("- Max attempts: `%d`\n", report.Runtime.MaxAttempts))
+ builder.WriteString("\n## Thresholds\n\n")
+ builder.WriteString(fmt.Sprintf("- Min style score: `%.1f`\n", report.Thresholds.MinStyleScore))
+ builder.WriteString(fmt.Sprintf("- Min keyword overlap: `%d`\n", report.Thresholds.MinKeywordOverlap))
+ builder.WriteString(fmt.Sprintf("- Min draft runes: `%d`\n", report.Thresholds.MinDraftRunes))
+ builder.WriteString("\n## Endpoint\n\n")
+ builder.WriteString(fmt.Sprintf("- Checked: `%v`\n", report.Endpoint.Checked))
+ builder.WriteString(fmt.Sprintf("- Available: `%v`\n", report.Endpoint.Available))
+ if report.Endpoint.Error != "" {
+ builder.WriteString(fmt.Sprintf("- Error: `%s`\n", report.Endpoint.Error))
+ }
+ if len(report.Endpoint.Models) > 0 {
+ builder.WriteString(fmt.Sprintf("- Models: `%s`\n", strings.Join(report.Endpoint.Models, ", ")))
+ }
+ if report.Result.Score > 0 || report.Result.Runes > 0 || report.Result.ElapsedSeconds > 0 {
+ builder.WriteString("\n## Result\n\n")
+ builder.WriteString(fmt.Sprintf("- Scenario passed: `%v`\n", report.Result.ScenarioPassed))
+ builder.WriteString(fmt.Sprintf("- Elapsed seconds: `%.2f`\n", report.Result.ElapsedSeconds))
+ builder.WriteString(fmt.Sprintf("- Score: `%.1f / %.1f`\n", report.Result.Score, report.Thresholds.MinStyleScore))
+ builder.WriteString(fmt.Sprintf("- Keyword overlap: `%d / %d`\n", report.Result.KeywordOverlap, report.Thresholds.MinKeywordOverlap))
+ builder.WriteString(fmt.Sprintf("- Runes: `%d / %d`\n", report.Result.Runes, report.Thresholds.MinDraftRunes))
+ builder.WriteString(fmt.Sprintf("- Verification: `performed=%v passed=%v`\n", report.Result.VerificationPerformed, report.Result.VerificationPassed))
+ }
+ if len(report.Commands) > 0 {
+ builder.WriteString("\n## Commands\n\n")
+ for _, command := range report.Commands {
+ builder.WriteString(fmt.Sprintf("- `%s`: exit `%d`, elapsed `%.2fs`, stdout `%s`, stderr `%s`\n", command.Label, command.ExitCode, command.ElapsedSeconds, command.StdoutPath, command.StderrPath))
+ }
+ }
+ if len(report.Remaining) > 0 {
+ builder.WriteString("\n## Remaining\n\n")
+ for _, item := range report.Remaining {
+ builder.WriteString("- " + item + "\n")
+ }
+ }
+ builder.WriteString("\n## Artifacts\n\n")
+ builder.WriteString(fmt.Sprintf("- JSON: `%s`\n", report.Artifacts.JSON))
+ builder.WriteString(fmt.Sprintf("- Draft: `%s`\n", report.Artifacts.Draft))
+ builder.WriteString(fmt.Sprintf("- Evaluation: `%s`\n", report.Artifacts.Evaluation))
+ builder.WriteString(fmt.Sprintf("- Verification: `%s`\n", report.Artifacts.Verification))
+ return builder.String()
+}
+
+func printPlan(report scenarioReport) {
+ fmt.Printf("local llama.cpp fallback validation plan recorded\n")
+ fmt.Printf("status=%s\n", report.Status)
+ fmt.Printf("explicit_run=%v\n", report.ExplicitRun)
+ fmt.Printf("endpoint_available=%v\n", report.Endpoint.Available)
+ if report.Endpoint.Error != "" {
+ fmt.Printf("endpoint_error=%s\n", report.Endpoint.Error)
+ }
+ fmt.Printf("base_url=%s\n", report.Runtime.BaseURL)
+ fmt.Printf("model=%s\n", report.Runtime.Model)
+ fmt.Printf("min_style_score=%.1f\n", report.Thresholds.MinStyleScore)
+ fmt.Printf("min_keyword_overlap=%d\n", report.Thresholds.MinKeywordOverlap)
+ fmt.Printf("min_draft_runes=%d\n", report.Thresholds.MinDraftRunes)
+ fmt.Printf("load_flags=%s\n", report.Runtime.LoadFlags)
+ fmt.Printf("report=%s\n", report.Artifacts.Report)
+}
+
+func mustWriteJSON(path string, value any) {
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatalf("encode %s: %v", path, err)
+ }
+ mustWriteFile(path, string(encoded)+"\n")
+}
+
+func mustWriteFile(path string, content string) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ fatalf("create dir for %s: %v", path, err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ fatalf("write %s: %v", path, err)
+ }
+}
+
+func sanitizeLabel(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ var builder strings.Builder
+ for _, r := range value {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ builder.WriteRune(r)
+ continue
+ }
+ builder.WriteByte('_')
+ }
+ return strings.Trim(builder.String(), "_")
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func envInt(key string, fallback int) int {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
+func envFloat(key string, fallback float64) float64 {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
+func parseBool(value string) bool {
+ parsed, _ := strconv.ParseBool(strings.TrimSpace(value))
+ return parsed
+}
+
+func parseInt(value string) int {
+ parsed, _ := strconv.Atoi(strings.TrimSpace(value))
+ return parsed
+}
+
+func parseFloat(value string) float64 {
+ parsed, _ := strconv.ParseFloat(strings.TrimSpace(value), 64)
+ return parsed
+}
+
+func boolEnv(value bool) string {
+ if value {
+ return "1"
+ }
+ return "0"
+}
+
+func valueOrNone(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "(not recorded)"
+ }
+ return value
+}
+
+func fatalf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/cmd/scenario/local_llamacpp_fallback/main_test.go b/cmd/scenario/local_llamacpp_fallback/main_test.go
new file mode 100644
index 0000000..3ca5b03
--- /dev/null
+++ b/cmd/scenario/local_llamacpp_fallback/main_test.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRequireLoopbackBaseURLRejectsEvoX2AndRemoteHosts(t *testing.T) {
+ for _, raw := range []string{
+ "http://evo-x2.tailb30e58.ts.net/v1",
+ "http://192.168.1.10:8081/v1",
+ "http://example.test/v1",
+ } {
+ if err := requireLoopbackBaseURL(raw); err == nil {
+ t.Fatalf("expected %s to be rejected", raw)
+ }
+ }
+}
+
+func TestRequireLoopbackBaseURLAcceptsLocalhostAndLoopback(t *testing.T) {
+ for _, raw := range []string{
+ "http://localhost:8081/v1",
+ "http://127.0.0.1:8081/v1",
+ "http://[::1]:8081/v1",
+ } {
+ if err := requireLoopbackBaseURL(raw); err != nil {
+ t.Fatalf("expected %s to be accepted: %v", raw, err)
+ }
+ }
+}
+
+func TestResultFromDraftOutputParsesThresholdMetrics(t *testing.T) {
+ output := strings.Join([]string{
+ "draft generation scenario completed",
+ "scenario_passed=true",
+ "attempt=2",
+ "selected_attempt=2",
+ "passed=true",
+ "score=84.5",
+ "keyword_overlap=72",
+ "runes=3012",
+ "verification_performed=true",
+ "verification_passed=true",
+ "elapsed_seconds=123.45",
+ "streaming=true",
+ "first_chunk_ms=1200",
+ "chunks=44",
+ }, "\n")
+
+ result := resultFromDraftOutput(output)
+
+ if !result.ScenarioPassed || result.Attempt != 2 || result.Score != 84.5 || result.KeywordOverlap != 72 || result.Runes != 3012 {
+ t.Fatalf("unexpected parsed result: %#v", result)
+ }
+ if !result.VerificationPerformed || !result.VerificationPassed || result.FirstChunkMs != 1200 || result.Chunks != 44 {
+ t.Fatalf("stream/verification fields not parsed: %#v", result)
+ }
+}
+
+func TestCommandEnvClearsFallbackChainAndPinsLocalRuntime(t *testing.T) {
+ t.Setenv("LLM_FALLBACK_BASE_URLS", "http://evo-x2.tailb30e58.ts.net/llama/v1")
+ t.Setenv("DRAFT_FALLBACK_LLM_BASE_URLS", "http://evo-x2.tailb30e58.ts.net/llama/v1")
+ config := scenarioConfig{
+ BaseURL: "http://127.0.0.1:8081/v1",
+ Model: "local-qwen",
+ VerifyModel: "local-verify",
+ MinStyleScore: 82,
+ MinKeywordOverlap: 70,
+ MinDraftRunes: 2800,
+ MaxAttempts: 2,
+ TimeoutSeconds: 900,
+ StreamDraft: true,
+ }
+
+ env := keyValueEnv(commandEnv(config, nil))
+
+ if env["LLM_BASE_URL"] != config.BaseURL || env["DRAFT_LLM_MODEL"] != config.Model || env["VERIFY_LLM_MODEL"] != config.VerifyModel {
+ t.Fatalf("local runtime env not pinned: %#v", env)
+ }
+ if env["LLM_FALLBACK_BASE_URLS"] != "" || env["DRAFT_LLM_FALLBACK_BASE_URLS"] != "" || env["DRAFT_FALLBACK_LLM_BASE_URLS"] != "" {
+ t.Fatalf("fallback chain was not cleared: %#v", env)
+ }
+}
+
+func TestLocalFallbackPassedRequiresIssue36KeywordGate(t *testing.T) {
+ report := scenarioReport{
+ Thresholds: thresholdReport{
+ MinStyleScore: 82,
+ MinKeywordOverlap: 70,
+ MinDraftRunes: 2800,
+ },
+ Result: resultReport{
+ ScenarioPassed: true,
+ Score: 84.5,
+ KeywordOverlap: 69,
+ Runes: 3200,
+ VerificationPassed: true,
+ },
+ }
+
+ if localFallbackPassed(report) {
+ t.Fatal("keyword_overlap below #36 threshold must fail even when the upstream draft scenario passed")
+ }
+ report.Result.KeywordOverlap = 70
+ if !localFallbackPassed(report) {
+ t.Fatalf("expected #36 gate to pass at exact thresholds: %#v", report)
+ }
+}
+
+func keyValueEnv(values []string) map[string]string {
+ out := map[string]string{}
+ for _, entry := range values {
+ key, value, _ := strings.Cut(entry, "=")
+ out[key] = value
+ }
+ return out
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 393193a..9a234e2 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -46,6 +46,8 @@ func registerRoutes(r *mux.Router) {
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/personas/{id}", handlers.UpdatePersonaHandler).Methods("PATCH")
+ r.HandleFunc("/api/personas/{id}", handlers.DeletePersonaHandler).Methods("DELETE")
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")
@@ -60,6 +62,7 @@ func registerRoutes(r *mux.Router) {
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}/versions", handlers.ListBriefVersionsHandler).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")
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
index 4a03bea..582331f 100644
--- a/cmd/server/main_test.go
+++ b/cmd/server/main_test.go
@@ -31,9 +31,12 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
{method: http.MethodGet, path: "/api/models"},
{method: http.MethodGet, path: "/api/personas"},
{method: http.MethodPost, path: "/api/personas"},
+ {method: http.MethodPatch, path: "/api/personas/custom-writer"},
+ {method: http.MethodDelete, path: "/api/personas/custom-writer"},
{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.MethodGet, path: "/api/briefs/session-1/versions"},
{method: http.MethodPatch, path: "/api/briefs/session-1"},
} {
request, err := http.NewRequest(tt.method, tt.path, nil)
diff --git a/docs/adrs/0001-three-phase-local-article-generation.md b/docs/adrs/0001-three-phase-local-article-generation.md
index 9c3a59b..1c2e6ed 100644
--- a/docs/adrs/0001-three-phase-local-article-generation.md
+++ b/docs/adrs/0001-three-phase-local-article-generation.md
@@ -105,7 +105,7 @@ Adapters remain outside the domain:
2. Evo X2 `llama.cpp` / `llama-server` OpenAI-compatible API, normally `http://evo-x2.tailb30e58.ts.net/llama/v1`.
3. Workstation-local `llama.cpp`, normally `http://127.0.0.1:8081/v1`, as the last resort only.
- SSH port forwarding is a developer diagnostic path only. It must not be the product default because it depends on per-device SSH configuration and prevents other authorized Tailnet devices from using the shared Evo X2 endpoint.
- - `llama.cpp` model swapping remains a later operational hardening item tracked by Issue [#45](https://github.com/terisuke/note_maker/issues/45). Until model swap/restart orchestration is reliable, `llama-server` is a fallback route rather than the primary multi-model route.
+ - `llama.cpp` model swapping is tracked by Issue [#45](https://github.com/terisuke/note_maker/issues/45). The current recommended implementation is a conservative systemd/profile swap for one shared fallback `llama-server` behind `/llama/v1`, with dry-run default scripts and explicit restart gates. Until live Evo X2 validation proves brief/draft quality, first-token latency, and no Ollama disruption, `llama-server` remains a fallback route rather than the primary multi-model route.
- Direct local Ollama on `127.0.0.1:11434` must not be used as the default verification path; it is only acceptable when explicitly selected for a one-off diagnostic.
- Scenario output must record the base URL, model, elapsed time, style score, and draft length so accidental runtime swaps are visible.
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index 6335c63..c9997e5 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -253,13 +253,14 @@ Current implementation status as of 2026-05-03:
- The follow-up #74 validation completed the current publishing-target acceptance scope. On 2026-05-03, the full Tailnet Evo X2 matrix passed all five article targets: note, two Cor.inc company-blog modes, Zenn, and Qiita. The run used Evo X2 Ollama primary at `http://evo-x2.tailb30e58.ts.net/v1`, `gemma4:31b` for draft generation, and `gemma4:latest` for lightweight final verification. Results are recorded in [Issue #74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md) and `tmp/media_matrix/live/aggregate.{json,md}`. Summary: `5/5` passed, average `122.01s`, average style score `86.0`, average `3742` runes, all final verification and structural gates passed.
- The #74 pass required additional hardening that is now part of the architecture: frontmatter preamble/fence normalization, recoverable frontmatter repair, bounded repair/revision/final-verification calls, case-specific style profiles, final verifier format-guide grounding, best-attempt selection across retries, and explicit quality-gate aggregate output.
- The #70-#73 prerequisite slice is now in place for #74: interview-template coverage exists, failed draft artifacts are preserved, format-only failures can be repaired once without relaxing validators, and scenario gates are split by output format. The first staged #74 Tailnet rerun used `cloudia_zenn_tutorial` and moved past the original failure class but failed strict style (`73.6 / 82.0`). The current cut fixes the reliability gap behind that score by generating case-specific style profile/guide artifacts, passing them into live runs, rejecting profile/guide/brief mismatches, making final verification block `scenario_passed`, enforcing structural signals, and exposing UI `quality_gate` details. The bounded reruns now pass both Cloudia technical proofs: `cloudia_zenn_tutorial` scored `88.1 / 82.0` with `5040 / 1800` runes, and `cloudia_qiita_how_to` scored `83.7 / 82.0` with `3318 / 1400` runes. Both used the Tailnet endpoint `http://evo-x2.tailb30e58.ts.net/v1` and passed lightweight verification. Validation is recorded in [Issue 74 staged Evo X2 rerun](../validation/issue-74-staged-evo-x2-rerun-2026-05-03.md).
+- The #45 llama.cpp swap orchestration cut now has a documented conservative fallback strategy, dry-run default Make targets, and a gated direct `/llama/v1` brief/draft scenario target. It does not change the primary runtime decision: Ollama remains primary until the live #45 criteria prove active-profile quality and operations safety.
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. 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.
+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. For #45, design/script acceptance can land before closure, but live direct-llama.cpp brief/draft validation remains required.
## Tracked issues
diff --git a/docs/implementation-plans/issue-adr-guardrails.md b/docs/implementation-plans/issue-adr-guardrails.md
index 73163ad..31f374f 100644
--- a/docs/implementation-plans/issue-adr-guardrails.md
+++ b/docs/implementation-plans/issue-adr-guardrails.md
@@ -20,6 +20,7 @@ Active issues that ADR 0002 reframes (see [ADR 0002 — Tracked issues](../adrs/
| [#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. |
+| [#45](https://github.com/terisuke/note_maker/issues/45) | Evo X2 llama.cpp swap orchestration | ADR 0001/0002 runtime validation | Non-blocking P2. Keep Ollama primary; llama.cpp swap/start commands must be dry-run by default, require explicit restart gates, target `/llama/v1` directly for validation, and remain open until live brief/draft metrics pass without disrupting Ollama. |
| [#40](https://github.com/terisuke/note_maker/issues/40) | Tailnet Evo X2 primary quality and runtime metrics epic | ADR 0001/0002 runtime validation | Primary runtime must record endpoint/model/elapsed/score/runes and distinguish generation variance from transport failures. The current note/Qiita/Zenn/Cor blog publishing-target scope passed on 2026-05-03 with a `5/5` full Tailnet Evo X2 matrix run. |
| [#57](https://github.com/terisuke/note_maker/issues/57) | Live media-matrix runner and aggregate evaluator | ADR 0001/0002 runtime validation | Child of #40. Offline mode remains default; live mode must require explicit env vars and must refuse accidental workstation-local fallback for primary Evo X2 validation. |
| [#70](https://github.com/terisuke/note_maker/issues/70) | Interview-template scenario before Evo X2 media runs | ADR 0002 §Testing Strategy | The question-template change must be tested before draft-only live runs. Scenario output must prove small plain-Japanese questions and medium-specific `ArticleBrief` artifacts. |
@@ -82,6 +83,8 @@ The phases in [ADR 0002](../adrs/0002-multi-persona-multi-format-extension.md) (
- SSH tunnels are allowed only as explicit developer diagnostics, not as the product default, because they depend on per-device SSH setup.
- Local llama.cpp (`http://127.0.0.1:8081/v1`) is fallback only. Do not set `LLM_BASE_URL` to local Ollama or local llama.cpp for Evo X2 validation unless the test is explicitly measuring fallback behavior.
- Runtime validation must report base URL, model, elapsed time, score, and draft length.
+ - Evo X2 llama.cpp swap/start orchestration for Issue [#45](https://github.com/terisuke/note_maker/issues/45) must be dry-run by default. Any remote start requires `EVO_X2_LLAMA_CPP_APPLY=1`; any profile swap/restart also requires `EVO_X2_LLAMA_CPP_ALLOW_RESTART=1`.
+ - #45 validation must set `LLM_BASE_URL` directly to Evo X2 `/llama/v1` and clear fallback URLs so the run cannot silently pass through Ollama.
- Each implementation PR that touches interview, prompt, draft, or runtime behavior should add one scenario datapoint with a deliberately varied medium/persona/format. If the PR touches question templates, the datapoint must come from the interview-template scenario rather than only draft generation. Do not force every PR to rerun every live scenario; build averages by collecting one different slice per phase. Use `cmd/scenario/media_matrix` as the canonical matrix for final Note/Qiita/Zenn/Cor blog comparison.
- Draft generation must run the lightweight final verification step before returning the final result; if verification reports NEEDS_REVIEW, surface the report instead of hiding it.
- If fallback validation fails the strict draft thresholds, keep Evo X2 primary enabled and track fallback hardening separately (Issue [#36](https://github.com/terisuke/note_maker/issues/36)).
diff --git a/docs/implementation-plans/multi-persona-multi-format.md b/docs/implementation-plans/multi-persona-multi-format.md
index 3a969e3..77aa155 100644
--- a/docs/implementation-plans/multi-persona-multi-format.md
+++ b/docs/implementation-plans/multi-persona-multi-format.md
@@ -309,7 +309,7 @@ Acceptance:
Issue [#11](https://github.com/terisuke/note_maker/issues/11) (style threshold tuning) and Issue [#13](https://github.com/terisuke/note_maker/issues/13) (Playwright E2E) are tracked separately but their acceptance criteria are folded into Phase D's exit gate.
-Runtime validation treats Evo X2 Ollama's OpenAI-compatible API over Tailscale VPN/MagicDNS as the primary heavy-inference path. SSH tunnels are explicit developer diagnostics only. The fallback chain is Evo X2 Ollama → Evo X2 llama.cpp → workstation-local llama.cpp. Scenario reports must include base URL, model, elapsed time, score, and draft length to prevent accidental local-runtime validation. The 2026-05-02 validation passed on Evo X2 and found local fallback quality/model-compatibility gaps; fallback hardening is tracked in Issue [#36](https://github.com/terisuke/note_maker/issues/36). Future llama.cpp model swap orchestration is tracked in Issue [#45](https://github.com/terisuke/note_maker/issues/45).
+Runtime validation treats Evo X2 Ollama's OpenAI-compatible API over Tailscale VPN/MagicDNS as the primary heavy-inference path. SSH tunnels are explicit developer diagnostics only. The fallback chain is Evo X2 Ollama → Evo X2 llama.cpp → workstation-local llama.cpp. Scenario reports must include base URL, model, elapsed time, score, and draft length to prevent accidental local-runtime validation. The 2026-05-02 validation passed on Evo X2 and found local fallback quality/model-compatibility gaps; fallback hardening is tracked in Issue [#36](https://github.com/terisuke/note_maker/issues/36). Issue [#45](https://github.com/terisuke/note_maker/issues/45) now has a documented conservative Evo X2 llama.cpp service-profile swap strategy and dry-run default targets; closure still requires direct `/llama/v1` brief/draft live validation with streaming metrics and no Ollama disruption.
Draft generation now includes a lightweight final verification pass before returning the final result. The default operational model split is: `gemma4:e2b` for source/style summarization, `qwen3.6:27b` for follow-up question generation, `gemma4:31b` for Japanese draft generation, and `gemma4:latest` for final consistency verification. The verification step reports PASS/NEEDS_REVIEW plus concrete issues; automatic rewrite from the verification report is deferred until section regeneration and draft versioning are in place.
diff --git a/docs/research/issue-45-llamacpp-swap-orchestration.md b/docs/research/issue-45-llamacpp-swap-orchestration.md
new file mode 100644
index 0000000..57fba70
--- /dev/null
+++ b/docs/research/issue-45-llamacpp-swap-orchestration.md
@@ -0,0 +1,190 @@
+# Issue #45 - Evo X2 llama.cpp model swap orchestration
+
+Issue: [#45](https://github.com/terisuke/note_maker/issues/45)
+
+## Decision
+
+Keep Evo X2 Ollama as the primary runtime. Use Evo X2 `llama-server` only as a
+single active fallback profile behind the existing Tailnet/Caddy path:
+
+```text
+primary: http://evo-x2.tailb30e58.ts.net/v1
+fallback: http://evo-x2.tailb30e58.ts.net/llama/v1
+```
+
+Do not use llama.cpp as the primary multi-model router yet. The app needs
+phase-specific model selection for style, brief, article, draft, and verify
+calls; Ollama already supports that operational model per OpenAI-compatible
+request. The conservative llama.cpp path is an explicit service-profile swap:
+
+1. define one systemd service for the shared fallback path,
+2. keep profile env files under `/etc/note-maker/llama-cpp/`,
+3. point `/etc/note-maker/llama-cpp/active.env` at the selected profile,
+4. restart only the llama.cpp fallback service,
+5. verify `/llama/health` and `/llama/v1/models`,
+6. run brief/draft scenario validation before changing app fallback defaults.
+
+This keeps Ollama and other Evo X2 users unaffected unless they explicitly use
+the `/llama/v1` fallback endpoint during the maintenance window.
+
+## Rationale
+
+The current upstream `llama-server` documentation says `/v1/models` returns the
+loaded model and that the returned list has one element. It also documents
+`--alias` as the way to expose a custom model id. That model fits a stable
+"one active fallback alias" service better than per-phase dynamic routing.
+
+Upstream has newer router/profile work, but current issue traffic still shows
+operational risk around router mode and multiple model handling. In particular,
+a recent router-mode bug report described extra GPU memory use when loading
+models through `--models-preset` / model-directory router mode. Until Evo X2
+validation proves memory headroom and first-token latency with Ollama still
+serving primary traffic, multi-instance or router-mode llama.cpp should remain
+out of the default path.
+
+Primary sources:
+
+- `llama-server` README, `/v1/models` and `--alias`:
+- Multiple alias feature request:
+- Router-mode GPU memory regression report:
+
+## Recommended service shape
+
+Example systemd unit on Evo X2:
+
+```ini
+[Unit]
+Description=Note Maker llama.cpp fallback
+After=network-online.target
+
+[Service]
+EnvironmentFile=/etc/note-maker/llama-cpp/active.env
+ExecStart=/usr/local/bin/llama-server \
+ --model ${LLAMA_CPP_MODEL_PATH} \
+ --alias ${LLAMA_CPP_ALIAS} \
+ --host ${LLAMA_CPP_HOST} \
+ --port ${LLAMA_CPP_PORT} \
+ --ctx-size ${LLAMA_CPP_CTX_SIZE} \
+ ${LLAMA_CPP_EXTRA_ARGS}
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Example profile:
+
+```bash
+LLAMA_CPP_MODEL_PATH=/srv/models/gemma-4-E2B-it-Q8_0.gguf
+LLAMA_CPP_ALIAS=gemma-4-E2B-it-Q8_0.gguf
+LLAMA_CPP_HOST=127.0.0.1
+LLAMA_CPP_PORT=18081
+LLAMA_CPP_CTX_SIZE=8192
+LLAMA_CPP_EXTRA_ARGS=--jinja
+```
+
+Caddy should continue to publish only the fallback path, for example:
+
+```text
+/llama/* -> http://127.0.0.1:18081/*
+```
+
+## Phase aliases
+
+Because one active llama.cpp model is exposed at a time, the fallback model env
+vars should all point at the active alias for validation:
+
+```bash
+EVO_X2_LLAMA_CPP_MODEL=gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_STYLE_MODEL=gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_BRIEF_MODEL=gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_ARTICLE_MODEL=gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_DRAFT_MODEL=gemma-4-E2B-it-Q8_0.gguf
+EVO_X2_LLAMA_CPP_VERIFY_MODEL=gemma-4-E2B-it-Q8_0.gguf
+```
+
+If a profile is intended only for a narrow phase, keep it out of the app's
+default fallback chain and invoke it only through explicit scenario commands.
+
+## Operations commands
+
+Inspect active primary/fallback models and the configured remote service:
+
+```bash
+make evo-x2-llama-status
+```
+
+Print the selected start/swap plan without changing Evo X2:
+
+```bash
+make evo-x2-llama-plan
+```
+
+Dry-run a start:
+
+```bash
+make evo-x2-llama-start
+```
+
+Apply a start only when the service is known to be safe:
+
+```bash
+EVO_X2_LLAMA_CPP_APPLY=1 make evo-x2-llama-start
+```
+
+Dry-run a profile swap:
+
+```bash
+EVO_X2_LLAMA_CPP_PROFILE=fallback-gemma-e2b make evo-x2-llama-swap
+```
+
+Apply a profile swap only in a maintenance window:
+
+```bash
+EVO_X2_LLAMA_CPP_APPLY=1 \
+EVO_X2_LLAMA_CPP_ALLOW_RESTART=1 \
+EVO_X2_LLAMA_CPP_PROFILE=fallback-gemma-e2b \
+make evo-x2-llama-swap
+```
+
+## Validation gate
+
+The llama.cpp fallback cannot replace Ollama until it passes the same runtime
+signals used around #18's streaming work and current full workflow scenarios:
+
+- brief interview completes against the active fallback endpoint,
+- streamed draft generation reports `first_chunk_ms` and `chunks`,
+- `scenario_passed=true`,
+- `score >= SCENARIO_MIN_STYLE_SCORE`,
+- `runes >= SCENARIO_MIN_DRAFT_RUNES`,
+- final verification passes when performed,
+- report includes `llm_base_url`, `llm_model`, `verify_model`, and elapsed time.
+
+Live validation is intentionally gated:
+
+```bash
+RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 make scenario-evo-x2-llama-brief-draft
+```
+
+This command sets `LLM_BASE_URL` directly to `/llama/v1` and clears fallback
+URLs so the run cannot silently pass through Ollama.
+
+## ADR / plan alignment
+
+This strategy is consistent with ADR 0001, ADR 0002, and the implementation
+plans because it preserves the runtime order:
+
+1. Evo X2 Ollama Tailnet primary.
+2. Evo X2 llama.cpp fallback through `/llama/v1`.
+3. Workstation-local llama.cpp as last resort.
+
+It also preserves the current phase-model split. llama.cpp is not treated as a
+drop-in per-phase router because the selected conservative service shape exposes
+one active alias at a time. Any future multi-instance or router-mode approach
+must be validated as a separate operational change with memory/latency evidence
+while Ollama is still serving primary traffic.
+
+The #45 implementation therefore satisfies design/script readiness, but not
+live runtime acceptance. Keep the issue open until the pending criteria in
+`docs/validation/issue-45-llamacpp-swap-orchestration-2026-05-03.md` pass.
diff --git a/docs/validation/issue-15-launcher-2026-05-03.md b/docs/validation/issue-15-launcher-2026-05-03.md
new file mode 100644
index 0000000..f5b8d00
--- /dev/null
+++ b/docs/validation/issue-15-launcher-2026-05-03.md
@@ -0,0 +1,46 @@
+# Issue 15 launcher validation
+
+Date: 2026-05-03
+
+Scope: packaging/dev launcher files only.
+
+## Implemented behavior
+
+- Added `scripts/launcher.sh` as an app-like launcher for the Go web app.
+- Defaults to Evo X2 Tailnet as the primary OpenAI-compatible LLM health path.
+- Does not start local `llama-server` unless `--local-llm`, `make launcher-local`, or `NOTE_MAKER_START_LOCAL_LLM=1` is used.
+- Selects the requested port or the next available port unless `--strict-port` is set.
+- Uses a user data directory for app config, workflow store, logs, and the built server binary.
+- Stops the managed server process on `SIGTERM`, `SIGINT`, or shell exit.
+
+## Validation
+
+```bash
+./scripts/check-launcher.sh
+```
+
+Result: passed. This ran `bash -n` for launcher-related scripts, `shellcheck` when available, the launcher shell tests, and Make dry-runs for launcher targets.
+
+```bash
+go test ./...
+```
+
+Result: passed.
+
+```bash
+make launcher-status
+```
+
+Result: passed. Evo X2 Tailnet primary was reachable at `http://evo-x2.tailb30e58.ts.net/v1/models`; Evo X2 llama.cpp fallback was reachable at `http://evo-x2.tailb30e58.ts.net/llama/v1/models`; local fallback `http://127.0.0.1:8081/v1/models` was not running and was not started.
+
+```bash
+./scripts/launcher.sh --no-open
+kill -TERM
+lsof -nP -iTCP:8080 -sTCP:LISTEN
+```
+
+Result: launcher built the server binary, started the app on `http://127.0.0.1:8080`, then stopped the managed server after `SIGTERM`; no listener remained on port 8080.
+
+## Closure status
+
+This substantially implements issue #15 with a pragmatic shell launcher. A full Tauri/Electron wrapper, app icon, signing, and installer packaging remain future packaging work, but the user-facing startup path, process lifecycle, health checks, storage defaults, and Make/mise entrypoints are in place.
diff --git a/docs/validation/issue-36-local-llamacpp-fallback-status-2026-05-03.md b/docs/validation/issue-36-local-llamacpp-fallback-status-2026-05-03.md
new file mode 100644
index 0000000..217057e
--- /dev/null
+++ b/docs/validation/issue-36-local-llamacpp-fallback-status-2026-05-03.md
@@ -0,0 +1,95 @@
+# Issue #36 local llama.cpp fallback status - 2026-05-03
+
+## Summary for Issue/PR
+
+Issue #36 is substantially implemented from the validation-tooling side, but it should not close yet because no real local llama.cpp fallback draft run was executed on available local hardware/model runtime.
+
+The new dedicated fallback validation command is in place:
+
+```sh
+make scenario-local-llamacpp-fallback
+```
+
+The live draft run is intentionally gated and requires:
+
+```sh
+RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1 \
+LOCAL_LLAMACPP_FALLBACK_BASE_URL=http://127.0.0.1:8081/v1 \
+LOCAL_LLAMACPP_FALLBACK_MODEL=qwen3:30b-a3b \
+LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL=qwen3:30b-a3b \
+LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS='--host 127.0.0.1 --port 8081 --alias qwen3:30b-a3b --reasoning off ...' \
+make scenario-local-llamacpp-fallback
+```
+
+Without `RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1`, the command records a plan/report only. It does not start Ollama, does not start `llama-server`, and does not run heavy local inference.
+
+## Why this cannot close yet
+
+The dry-run validation checked the configured loopback endpoint:
+
+- Base URL: `http://127.0.0.1:8081/v1`
+- Model: `qwen3:30b-a3b`
+- Verify model: `qwen3:30b-a3b`
+- Output report: `tmp/local_llamacpp_fallback/report.md`
+- JSON report: `tmp/local_llamacpp_fallback/report.json`
+
+The endpoint was not available:
+
+```text
+list llama.cpp models: Get "http://127.0.0.1:8081/v1/models": dial tcp 127.0.0.1:8081: connect: connection refused
+```
+
+Because `/v1/models` returned `connection refused`, the runner correctly stayed in `plan_only` mode and did not attempt draft generation. This means the implementation path exists, but there is still no hardware/model evidence that local llama.cpp fallback can meet the draft quality gate.
+
+## Pass/fail thresholds for closure
+
+To close #36, a future live run must record all of the following in `tmp/local_llamacpp_fallback/report.json`:
+
+| Field | Required value |
+|---|---:|
+| `status` | `passed` |
+| `explicit_run` | `true` |
+| `runtime.local_fallback_only` | `true` |
+| `runtime.fallback_chain_disabled` | `true` |
+| `endpoint.available` | `true` |
+| `thresholds.min_style_score` | `82.0` |
+| `thresholds.min_keyword_overlap` | `70` |
+| `thresholds.min_draft_runes` | `2800` |
+| `result.scenario_passed` | `true` |
+| `result.score` | `>= 82.0` |
+| `result.keyword_overlap` | `>= 70` |
+| `result.runes` | `>= 2800` |
+| `result.verification_passed` | `true` when verification is performed |
+
+The report must also preserve the exact base URL, model alias, verify model, elapsed seconds, score, keyword overlap, runes, and `LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS` used for the real hardware/model run.
+
+## Evo X2 primary safety
+
+This validation path is separate from Evo X2 primary validation:
+
+- The default primary remains Evo X2 Ollama through `make scenario-evo-x2`.
+- The local fallback runner accepts only loopback hosts such as `127.0.0.1`, `localhost`, or `::1`.
+- Evo X2 hostnames are rejected by the local fallback runner.
+- The runner clears the normal fallback chain before draft generation, so a local fallback result cannot be confused with Evo X2 primary or Evo X2 llama.cpp fallback.
+- Plan mode is the default unless the explicit live env var is present.
+
+Therefore, the #36 tooling does not block or alter the Evo X2 primary path.
+
+## Validation performed
+
+Commands run after the implementation:
+
+```sh
+go test ./cmd/scenario/local_llamacpp_fallback
+make scenario-local-llamacpp-fallback
+```
+
+Expected current outcome:
+
+- The Go test passes.
+- The Make target succeeds in `plan_only` mode.
+- The Make target records `connection refused` if no local llama.cpp endpoint is listening on `127.0.0.1:8081`.
+
+## Closure decision
+
+Do not close #36 yet. Close it only after a gated live run against an already-running local llama.cpp endpoint records `status=passed`, `result.score >= 82.0`, `result.keyword_overlap >= 70`, `result.runes >= 2800`, and the exact load flags used for the model.
diff --git a/docs/validation/issue-36-local-llamacpp-fallback-template.md b/docs/validation/issue-36-local-llamacpp-fallback-template.md
new file mode 100644
index 0000000..9d3c92d
--- /dev/null
+++ b/docs/validation/issue-36-local-llamacpp-fallback-template.md
@@ -0,0 +1,85 @@
+# Issue #36 local llama.cpp fallback validation template
+
+Date: YYYY-MM-DD
+
+## Purpose
+
+Validate only the workstation-local llama.cpp fallback path. This is not Evo X2 primary validation and must not use the normal fallback chain.
+
+## Required live gate
+
+Live draft generation requires:
+
+```sh
+RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1
+```
+
+Without that variable, the runner writes a plan/report and exits without starting or using a local model. The runner never starts Ollama or `llama-server`.
+
+## Endpoint and load flags
+
+Record the exact already-running llama.cpp endpoint:
+
+- Base URL: `http://127.0.0.1:8081/v1`
+- Model alias: `qwen3:30b-a3b`
+- Load flags: `PASTE llama-server flags here`
+
+Example command to record a plan or unavailable-endpoint report:
+
+```sh
+make scenario-local-llamacpp-fallback
+```
+
+Example live command after the local llama.cpp endpoint is already available:
+
+```sh
+RUN_LOCAL_LLAMACPP_FALLBACK_SCENARIO=1 \
+LOCAL_LLAMACPP_FALLBACK_BASE_URL=http://127.0.0.1:8081/v1 \
+LOCAL_LLAMACPP_FALLBACK_MODEL=qwen3:30b-a3b \
+LOCAL_LLAMACPP_FALLBACK_VERIFY_MODEL=qwen3:30b-a3b \
+LOCAL_LLAMACPP_FALLBACK_LOAD_FLAGS='--host 127.0.0.1 --port 8081 --alias qwen3:30b-a3b --reasoning off ...' \
+make scenario-local-llamacpp-fallback
+```
+
+## Required artifacts
+
+The dedicated runner writes:
+
+- Report: `tmp/local_llamacpp_fallback/report.md`
+- JSON: `tmp/local_llamacpp_fallback/report.json`
+- Draft: `tmp/local_llamacpp_fallback/draft_generation/draft.md`
+- Evaluation: `tmp/local_llamacpp_fallback/draft_generation/evaluation.json`
+- Verification: `tmp/local_llamacpp_fallback/draft_generation/verification.json`
+- Command logs: `tmp/local_llamacpp_fallback/logs/*.stdout` and `*.stderr`
+
+## Acceptance threshold
+
+Record the final values from `report.json`:
+
+| Field | Required |
+|---|---:|
+| `runtime.local_fallback_only` | `true` |
+| `runtime.fallback_chain_disabled` | `true` |
+| `endpoint.available` | `true` |
+| `thresholds.min_style_score` | `82.0` |
+| `thresholds.min_keyword_overlap` | `70` |
+| `thresholds.min_draft_runes` | `2800` |
+| `result.scenario_passed` | `true` |
+| `result.score` | `>= 82.0` |
+| `result.keyword_overlap` | `>= 70` |
+| `result.runes` | `>= 2800` |
+
+## Result
+
+Paste the actual run summary:
+
+- Status:
+- Base URL:
+- Model:
+- Load flags:
+- Elapsed seconds:
+- Score / min:
+- Keyword overlap / min:
+- Runes / min:
+- Verification:
+- Issue #36 closure decision:
diff --git a/docs/validation/issue-45-llamacpp-swap-orchestration-2026-05-03.md b/docs/validation/issue-45-llamacpp-swap-orchestration-2026-05-03.md
new file mode 100644
index 0000000..52e39e2
--- /dev/null
+++ b/docs/validation/issue-45-llamacpp-swap-orchestration-2026-05-03.md
@@ -0,0 +1,233 @@
+# Issue #45 llama.cpp swap orchestration validation - 2026-05-03
+
+Issue: [#45](https://github.com/terisuke/note_maker/issues/45)
+
+## Scope
+
+This validation covers runtime operations design and local safety checks for
+Evo X2 llama.cpp fallback orchestration. It does not restart or kill Evo X2
+services.
+
+## Implemented controls
+
+- `scripts/evo-x2-llama-swap.sh inspect` reads the Ollama primary models,
+ llama.cpp fallback models, and remote systemd status when SSH is available.
+- `scripts/evo-x2-llama-swap.sh plan` prints the systemd profile strategy and
+ start/swap commands without changing Evo X2.
+- `scripts/evo-x2-llama-swap.sh start` is dry-run by default and requires
+ `EVO_X2_LLAMA_CPP_APPLY=1` for remote execution.
+- `scripts/evo-x2-llama-swap.sh swap` is dry-run by default and requires both
+ `EVO_X2_LLAMA_CPP_APPLY=1` and `EVO_X2_LLAMA_CPP_ALLOW_RESTART=1` before it
+ restarts the shared llama.cpp fallback service.
+- The script refuses to operate if the Ollama primary URL and llama.cpp fallback
+ URL are accidentally identical.
+
+## Make targets
+
+```bash
+make evo-x2-llama-status
+make evo-x2-llama-plan
+make evo-x2-llama-start
+make evo-x2-llama-swap
+make evo-x2-llama-check
+RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 make scenario-evo-x2-llama-brief-draft
+```
+
+The live brief/draft scenario is explicitly gated by
+`RUN_EVO_X2_LLAMA_CPP_SCENARIO=1`. It targets
+`EVO_X2_LLAMA_CPP_LLM_BASE_URL` directly and clears fallback URLs, so it cannot
+silently pass through Ollama.
+
+## Local validation commands
+
+```bash
+make evo-x2-llama-check
+make -n evo-x2-llama-status
+make -n evo-x2-llama-plan
+make -n evo-x2-llama-start
+make -n evo-x2-llama-swap
+make -n scenario-evo-x2-llama-brief-draft
+```
+
+Results:
+
+- `make evo-x2-llama-check`: passed; `bash -n` accepted `scripts/dev.sh`,
+ `scripts/evo-x2-tailnet-preflight.sh`, `scripts/evo-x2-ssh-preflight.sh`,
+ and `scripts/evo-x2-llama-swap.sh`.
+- `make -n evo-x2-llama-status`: passed; printed the inspect command only.
+- `make -n evo-x2-llama-plan`: passed; printed the plan command only.
+- `make -n evo-x2-llama-start`: passed; printed the dry-run start command only.
+- `make -n evo-x2-llama-swap`: passed; printed the dry-run swap command only.
+- `make -n scenario-evo-x2-llama-brief-draft`: passed; printed the env-gated
+ scenario command without running it.
+- `./scripts/evo-x2-llama-swap.sh plan`: passed; printed start/swap commands
+ and post-check curls without SSH or service changes.
+- `./scripts/evo-x2-llama-swap.sh start`: passed; dry-run only.
+- `./scripts/evo-x2-llama-swap.sh swap`: passed; dry-run only.
+- `EVO_X2_LLAMA_CPP_APPLY=1 ./scripts/evo-x2-llama-swap.sh swap`: refused
+ before SSH because `EVO_X2_LLAMA_CPP_ALLOW_RESTART=1` was not set.
+- `make scenario-evo-x2-llama-brief-draft`: refused before preflight because
+ `RUN_EVO_X2_LLAMA_CPP_SCENARIO=1` was not set.
+- URL guard check with identical Ollama and llama.cpp URLs: refused with exit 2.
+- `make evo-x2-llama-status`: read-only check passed. Ollama primary reported
+ installed models including `gemma4:31b`, `gemma4:e2b`, `gemma4:latest`,
+ `qwen3.6:27b`, `qwen3:30b-a3b`, and `gpt-oss:120b`. The llama.cpp fallback
+ at `/llama/v1/models` reported one active model:
+ `gemma-4-E2B-it-Q8_0.gguf`. The default configured systemd service name
+ `note-maker-llama-cpp.service` reported `inactive`, so Evo X2's actual
+ service name should be supplied through `EVO_X2_LLAMA_CPP_SERVICE` before any
+ apply-mode start/swap command.
+- `go test ./...`: passed.
+
+## Live validation command
+
+Use the same brief/draft scenario metrics used around #18 streaming and the
+current Evo X2 full workflow checks:
+
+```bash
+RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 \
+EVO_X2_LLAMA_CPP_MODEL=gemma-4-E2B-it-Q8_0.gguf \
+EVO_X2_LLAMA_CPP_STYLE_MODEL=gemma-4-E2B-it-Q8_0.gguf \
+EVO_X2_LLAMA_CPP_BRIEF_MODEL=gemma-4-E2B-it-Q8_0.gguf \
+EVO_X2_LLAMA_CPP_DRAFT_MODEL=gemma-4-E2B-it-Q8_0.gguf \
+EVO_X2_LLAMA_CPP_VERIFY_MODEL=gemma-4-E2B-it-Q8_0.gguf \
+make scenario-evo-x2-llama-brief-draft
+```
+
+Required reported metrics:
+
+- `scenario_passed`
+- `score`
+- `min_style_score`
+- `runes`
+- `min_draft_runes`
+- `verification_performed`
+- `verification_passed`
+- `elapsed_seconds`
+- `streaming`
+- `first_chunk_ms`
+- `chunks`
+- `llm_base_url`
+- `llm_model`
+- `verify_model`
+
+## Live Validation Pending Criteria
+
+Use this checklist as the concrete issue comment/update before closing #45.
+
+Preflight and service identity:
+
+- Record the real Evo X2 llama.cpp systemd unit name and pass it as
+ `EVO_X2_LLAMA_CPP_SERVICE`; the placeholder
+ `note-maker-llama-cpp.service` reported `inactive` during the read-only check.
+- Record the active profile name, active env path, model path or HF file,
+ exposed alias, context size, and GPU/Vulkan/RADV flags.
+- Run `make evo-x2-llama-status` before the scenario and paste the Ollama
+ primary `/v1/models` result plus the llama.cpp `/llama/v1/models` active
+ model. The llama.cpp result must show exactly the alias being validated.
+- If a profile swap is required, perform it only with both
+ `EVO_X2_LLAMA_CPP_APPLY=1` and `EVO_X2_LLAMA_CPP_ALLOW_RESTART=1`, and record
+ the maintenance window or operator confirmation. No command may kill or
+ restart Ollama.
+
+Direct llama.cpp scenario:
+
+- Run `RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 make scenario-evo-x2-llama-brief-draft`
+ or the equivalent expanded command from this document.
+- Confirm the scenario output includes
+ `llm_base_url=http://evo-x2.tailb30e58.ts.net/llama/v1` or the configured
+ `EVO_X2_LLAMA_CPP_LLM_BASE_URL`.
+- Confirm `LLM_FALLBACK_BASE_URLS` is empty for the run so success cannot come
+ from Ollama or workstation-local fallback.
+- Attach or link the generated `tmp/author_style`, `tmp/brief_interview`, and
+ `tmp/draft_generation` artifacts, especially `draft.md`, `evaluation.json`,
+ and `verification.json`.
+
+Pass gates:
+
+- Brief phase completes against `/llama/v1`.
+- Draft phase streams with `streaming=true`, `chunks > 0`, and a recorded
+ `first_chunk_ms`.
+- Warm first chunk meets the #18-style target of `first_chunk_ms <= 3000`; if
+ it does not, keep #45 open and record the observed latency as an operations
+ miss.
+- `scenario_passed=true`.
+- `score >= 80.0`.
+- `runes >= 2800`.
+- `verification_performed=true` and `verification_passed=true`, or an explicit
+ reason is recorded if final verification is intentionally unavailable for the
+ active llama.cpp profile.
+- `elapsed_seconds`, `llm_model`, and `verify_model` are recorded.
+
+Ollama safety gates:
+
+- Run `make evo-x2-models` or equivalent Ollama `/v1/models` check after the
+ llama.cpp scenario; it must still pass.
+- The run must not change the app default primary from Ollama to llama.cpp.
+- No other Evo X2 users should lose the shared Ollama Tailnet primary during
+ the test. If there is any service interruption or resource starvation,
+ record it and keep #45 open.
+
+Close #45 only when every pass and safety gate above is satisfied. Otherwise,
+comment on #45 with the failed gate, observed metrics, active profile, and next
+specific follow-up.
+
+## Live validation result - 2026-05-03
+
+Command:
+
+```bash
+RUN_EVO_X2_LLAMA_CPP_SCENARIO=1 make scenario-evo-x2-llama-brief-draft
+```
+
+Path controls:
+
+- `llm_base_url=http://evo-x2.tailb30e58.ts.net/llama/v1`
+- `LLM_FALLBACK_BASE_URLS=""`
+- `STYLE/BRIEF/ARTICLE/DRAFT/VERIFY_LLM_FALLBACK_MODELS=""`
+- No swap, restart, or apply-mode command was run.
+- Post-run `make evo-x2-models` passed, so the Ollama primary endpoint remained
+ available after the direct llama.cpp scenario.
+
+Observed output:
+
+| Metric | Value |
+|---|---:|
+| brief elapsed seconds | `42.59` |
+| draft elapsed seconds | `59.33` |
+| scenario_passed | `true` |
+| score | `88.2 / 80.0` |
+| keyword_overlap | `65 / 70` |
+| runes | `3075 / 2800` |
+| verification_performed | `true` |
+| verification_passed | `true` |
+| streaming | `true` |
+| first_chunk_ms | `14105` |
+| chunks | `1785` |
+| llm_model | `gemma-4-E2B-it-Q8_0.gguf` |
+| verify_model | `gemma-4-E2B-it-Q8_0.gguf` |
+
+Artifacts:
+
+- `tmp/author_style/profile.json`
+- `tmp/author_style/guide.md`
+- `tmp/brief_interview/brief.json`
+- `tmp/draft_generation/draft.md`
+- `tmp/draft_generation/evaluation.json`
+- `tmp/draft_generation/verification.json`
+
+Outcome:
+
+- The direct `/llama/v1` scenario can complete without falling back to Ollama.
+- Draft throughput and final verification were good enough for a fallback proof.
+- It is not ready to promote over Ollama: strict style evaluation failed on
+ `keyword_overlap=65 below 70`, and first chunk latency was `14105ms`, above
+ the #18-style streaming target.
+
+## Closure status
+
+#45 remains pending until the live Evo X2 llama.cpp brief/draft validation runs
+against the selected active profile and proves quality plus operations gates.
+The 2026-05-03 live run proves the route works, but it missed keyword-overlap
+and first-chunk gates. The current implementation is intentionally P2: Ollama
+remains primary.
diff --git a/internal/domain/brief/types.go b/internal/domain/brief/types.go
index b9bcf33..6aa6a2c 100644
--- a/internal/domain/brief/types.go
+++ b/internal/domain/brief/types.go
@@ -3,6 +3,7 @@ package brief
import (
"fmt"
"strings"
+ "time"
outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
"github.com/teradakousuke/note_maker/internal/domain/persona"
@@ -98,6 +99,14 @@ type ArticleBrief struct {
CustomAnswers []BriefAnswer
}
+// ArticleBriefVersion records one persisted revision of an assembled brief.
+type ArticleBriefVersion struct {
+ SessionID string
+ Version int
+ Brief ArticleBrief
+ CreatedAt time.Time
+}
+
// ArticleBriefSession owns article-interview state.
type ArticleBriefSession struct {
ID string
diff --git a/internal/domain/persona/persona.go b/internal/domain/persona/persona.go
index d950fc5..826250e 100644
--- a/internal/domain/persona/persona.go
+++ b/internal/domain/persona/persona.go
@@ -1,6 +1,7 @@
package persona
import (
+ "errors"
"fmt"
"regexp"
"strings"
@@ -13,6 +14,11 @@ const (
var customIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`)
+var (
+ ErrPersonaNotFound = errors.New("persona was not found")
+ ErrPersonaReferenced = errors.New("persona is referenced by workflow history")
+)
+
// AuthorSource identifies public material used to derive a persona's style.
type AuthorSource struct {
Kind string `json:"kind"`
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index 0a69726..f48bcd5 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"sort"
@@ -39,9 +40,11 @@ type workflowStoreBackend interface {
SaveBrief(string, briefdomain.ArticleBrief) error
GetBrief(string) (briefdomain.ArticleBrief, bool)
ListBriefs() (map[string]briefdomain.ArticleBrief, error)
+ ListBriefVersions(string) ([]briefdomain.ArticleBriefVersion, error)
SavePersona(personadomain.Persona) error
GetPersona(string) (personadomain.Persona, bool)
ListPersonas() ([]personadomain.Persona, error)
+ DeletePersona(string) error
GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool)
}
@@ -107,6 +110,15 @@ type createPersonaRequest struct {
VoiceNotes personadomain.VoiceNotes `json:"voice_notes"`
}
+type updatePersonaRequest 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"`
@@ -210,6 +222,24 @@ type briefArtifactListResponse struct {
Briefs []briefArtifactResponse `json:"briefs"`
}
+type briefVersionListResponse struct {
+ SessionID string `json:"session_id"`
+ CurrentVersion int `json:"current_version"`
+ Versions []briefVersionResponse `json:"versions"`
+}
+
+type briefVersionResponse struct {
+ SessionID string `json:"session_id"`
+ Version int `json:"version"`
+ Current bool `json:"current"`
+ CreatedAt string `json:"created_at,omitempty"`
+ Title string `json:"title,omitempty"`
+ StyleProfileID string `json:"style_profile_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ OutputFormatID string `json:"output_format_id,omitempty"`
+ Brief briefdomain.ArticleBrief `json:"brief"`
+}
+
type briefArtifactResponse struct {
SessionID string `json:"session_id"`
StyleProfileID string `json:"style_profile_id"`
@@ -591,6 +621,84 @@ func CreatePersonaHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusCreated, persona)
}
+// UpdatePersonaHandler updates a user-authored writing persona while preserving its ID.
+func UpdatePersonaHandler(w http.ResponseWriter, r *http.Request) {
+ personaID := pathValue(r, "id")
+ if _, ok := personadomain.DefaultRegistry().Get(personaID); ok {
+ respondWithError(w, "PERSONA_ID_RESERVED", "Built-in personas cannot be updated", personaID, http.StatusConflict)
+ return
+ }
+ persona, ok := workflowStore.GetPersona(personaID)
+ if !ok {
+ respondWithError(w, "PERSONA_NOT_FOUND", "Persona was not found", personaID, http.StatusNotFound)
+ return
+ }
+ var req updatePersonaRequest
+ if err := decodeJSONRequest(r, &req); err != nil {
+ respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.ID) != "" && strings.TrimSpace(req.ID) != personaID {
+ respondWithError(w, "IMMUTABLE_PERSONA_ID", "Persona id cannot be changed", req.ID, http.StatusBadRequest)
+ return
+ }
+ if req.DisplayName != nil {
+ persona.DisplayName = strings.TrimSpace(*req.DisplayName)
+ }
+ if req.Description != nil {
+ persona.Description = strings.TrimSpace(*req.Description)
+ }
+ if req.DefaultFormat != nil {
+ persona.DefaultFormat = strings.TrimSpace(*req.DefaultFormat)
+ }
+ if req.Sources != nil {
+ persona.Sources = *req.Sources
+ }
+ if req.VoiceNotes != nil {
+ persona.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 := 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.StatusOK, persona)
+}
+
+// DeletePersonaHandler removes a custom persona only when it is not referenced by history.
+func DeletePersonaHandler(w http.ResponseWriter, r *http.Request) {
+ personaID := pathValue(r, "id")
+ if _, ok := personadomain.DefaultRegistry().Get(personaID); ok {
+ respondWithError(w, "PERSONA_ID_RESERVED", "Built-in personas cannot be deleted", personaID, http.StatusConflict)
+ return
+ }
+ if err := workflowStore.DeletePersona(personaID); err != nil {
+ switch {
+ case errors.Is(err, personadomain.ErrPersonaNotFound):
+ respondWithError(w, "PERSONA_NOT_FOUND", "Persona was not found", personaID, http.StatusNotFound)
+ case errors.Is(err, personadomain.ErrPersonaReferenced):
+ respondWithError(w, "PERSONA_REFERENCED", "Persona is referenced by workflow history", personaID, http.StatusConflict)
+ default:
+ respondWithError(w, "PERSONA_DELETE_FAILED", "Failed to delete persona", err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
// ListFormatsHandler returns built-in output formats.
func ListFormatsHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, outputformat.DefaultRegistry().List())
@@ -1062,6 +1170,25 @@ func GetBriefArtifactHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, toBriefArtifactResponse(sessionID, articleBrief, session, sessionOK))
}
+// ListBriefVersionsHandler returns persisted versions for one completed brief artifact.
+func ListBriefVersionsHandler(w http.ResponseWriter, r *http.Request) {
+ sessionID := pathValue(r, "id")
+ if _, ok := workflowStore.GetBrief(sessionID); !ok {
+ respondWithError(w, "BRIEF_NOT_FOUND", "Brief was not found", sessionID, http.StatusNotFound)
+ return
+ }
+ versions, err := workflowStore.ListBriefVersions(sessionID)
+ if err != nil {
+ respondWithError(w, "BRIEF_VERSION_LIST_FAILED", "Failed to list brief versions", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, briefVersionListResponse{
+ SessionID: sessionID,
+ CurrentVersion: currentBriefVersion(versions),
+ Versions: toBriefVersionResponses(versions),
+ })
+}
+
// UpdateBriefArtifactHandler updates the saved brief artifact without rewriting session answers.
func UpdateBriefArtifactHandler(w http.ResponseWriter, r *http.Request) {
sessionID := pathValue(r, "id")
@@ -2194,6 +2321,35 @@ func toBriefSessionSummaryResponse(session briefdomain.ArticleBriefSession, arti
}
}
+func toBriefVersionResponses(versions []briefdomain.ArticleBriefVersion) []briefVersionResponse {
+ items := make([]briefVersionResponse, 0, len(versions))
+ currentVersion := currentBriefVersion(versions)
+ for _, version := range versions {
+ items = append(items, briefVersionResponse{
+ SessionID: version.SessionID,
+ Version: version.Version,
+ Current: version.Version == currentVersion,
+ CreatedAt: formatOptionalTime(version.CreatedAt),
+ Title: briefTitle(version.SessionID, version.Brief),
+ StyleProfileID: version.Brief.StyleProfileID,
+ PersonaID: version.Brief.PersonaID,
+ OutputFormatID: version.Brief.OutputFormatID,
+ Brief: version.Brief,
+ })
+ }
+ return items
+}
+
+func currentBriefVersion(versions []briefdomain.ArticleBriefVersion) int {
+ current := 0
+ for _, version := range versions {
+ if version.Version > current {
+ current = version.Version
+ }
+ }
+ return current
+}
+
func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) {
sort.SliceStable(sessions, func(i, j int) bool {
if sessions[i].Completed != sessions[j].Completed {
diff --git a/internal/handlers/workflow_edit_test.go b/internal/handlers/workflow_edit_test.go
index 9e9e629..76648c4 100644
--- a/internal/handlers/workflow_edit_test.go
+++ b/internal/handlers/workflow_edit_test.go
@@ -169,6 +169,32 @@ func TestUpdateBriefArtifactHandlerUpdatesSavedBriefWithoutRewritingSessionHisto
if savedSession.Answers[0].Content != originalThemeAnswer {
t.Fatalf("session answer history was rewritten: %#v", savedSession.Answers[0])
}
+
+ versionsRequest := httptest.NewRequest(http.MethodGet, "/api/briefs/session-brief-edit/versions", nil)
+ versionsRequest = mux.SetURLVars(versionsRequest, map[string]string{"id": session.ID})
+ versionsResponse := httptest.NewRecorder()
+
+ ListBriefVersionsHandler(versionsResponse, versionsRequest)
+
+ if versionsResponse.Code != http.StatusOK {
+ t.Fatalf("versions status = %d, body = %s", versionsResponse.Code, versionsResponse.Body.String())
+ }
+ var versions briefVersionListResponse
+ if err := json.NewDecoder(versionsResponse.Body).Decode(&versions); err != nil {
+ t.Fatalf("decode versions: %v", err)
+ }
+ if len(versions.Versions) != 2 {
+ t.Fatalf("versions = %#v, want 2 entries", versions.Versions)
+ }
+ if versions.SessionID != session.ID || versions.CurrentVersion != 2 {
+ t.Fatalf("unexpected version envelope: %#v", versions)
+ }
+ if versions.Versions[0].Version != 1 || versions.Versions[0].Brief.Theme == "Edited saved theme" {
+ t.Fatalf("original version was not preserved: %#v", versions.Versions[0])
+ }
+ if versions.Versions[1].Version != 2 || !versions.Versions[1].Current || versions.Versions[1].Title != "Edited saved theme" || versions.Versions[1].StyleProfileID != style.Profile.ID || versions.Versions[1].Brief.Theme != "Edited saved theme" {
+ t.Fatalf("edited version was not appended: %#v", versions.Versions[1])
+ }
}
func TestUpdateBriefArtifactHandlerValidatesFields(t *testing.T) {
diff --git a/internal/handlers/workflow_persona_test.go b/internal/handlers/workflow_persona_test.go
index e4cc7a7..a0fff04 100644
--- a/internal/handlers/workflow_persona_test.go
+++ b/internal/handlers/workflow_persona_test.go
@@ -7,6 +7,8 @@ import (
"net/http/httptest"
"testing"
+ "github.com/gorilla/mux"
+ 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"
"github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
@@ -53,6 +55,98 @@ func TestCreatePersonaHandlerStoresCustomPersonaAndListKeepsBuiltIns(t *testing.
}
}
+func TestUpdatePersonaHandlerPreservesIDAndStoresFields(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ if err := workflowStore.SavePersona(personadomain.Persona{
+ ID: "custom_writer",
+ DisplayName: "Custom Writer",
+ Description: "Old description",
+ DefaultFormat: outputformat.IDNoteArticle,
+ VoiceNotes: personadomain.VoiceNotes{
+ Tone: "Old tone.",
+ },
+ }); err != nil {
+ t.Fatalf("save persona: %v", err)
+ }
+
+ request := httptest.NewRequest(http.MethodPatch, "/api/personas/custom_writer", bytes.NewBufferString(`{
+ "display_name":"Updated Writer",
+ "default_format":"zenn_article",
+ "voice_notes":{"tone":"Sharper and more technical.","first_person":["私"]}
+ }`))
+ request = mux.SetURLVars(request, map[string]string{"id": "custom_writer"})
+ response := httptest.NewRecorder()
+
+ UpdatePersonaHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var updated personadomain.Persona
+ if err := json.NewDecoder(response.Body).Decode(&updated); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if updated.ID != "custom_writer" || updated.DisplayName != "Updated Writer" || updated.DefaultFormat != outputformat.IDZennArticle {
+ t.Fatalf("unexpected updated persona: %#v", updated)
+ }
+ restored, ok := workflowStore.GetPersona("custom_writer")
+ if !ok || restored.VoiceNotes.Tone != "Sharper and more technical." {
+ t.Fatalf("stored persona = %#v ok=%v", restored, ok)
+ }
+}
+
+func TestDeletePersonaHandlerRemovesUnreferencedCustomPersona(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ if err := workflowStore.SavePersona(personadomain.Persona{
+ ID: "custom_writer",
+ DisplayName: "Custom Writer",
+ DefaultFormat: outputformat.IDNoteArticle,
+ }); err != nil {
+ t.Fatalf("save persona: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodDelete, "/api/personas/custom_writer", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "custom_writer"})
+ response := httptest.NewRecorder()
+
+ DeletePersonaHandler(response, request)
+
+ if response.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if _, ok := workflowStore.GetPersona("custom_writer"); ok {
+ t.Fatal("persona was not deleted")
+ }
+}
+
+func TestDeletePersonaHandlerRejectsReferencedCustomPersona(t *testing.T) {
+ workflowStore = memory.NewWorkflowStore()
+ persona := personadomain.Persona{
+ ID: "custom_writer",
+ DisplayName: "Custom Writer",
+ DefaultFormat: outputformat.IDNoteArticle,
+ }
+ if err := workflowStore.SavePersona(persona); err != nil {
+ t.Fatalf("save persona: %v", err)
+ }
+ session, err := briefdomain.NewArticleBriefSessionWithOptions("session-custom-persona", "profile-1", persona.ID, outputformat.IDNoteArticle, "", briefdomain.FixedQuestions())
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := workflowStore.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ request := httptest.NewRequest(http.MethodDelete, "/api/personas/custom_writer", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "custom_writer"})
+ response := httptest.NewRecorder()
+
+ DeletePersonaHandler(response, request)
+
+ assertErrorResponse(t, response, http.StatusConflict, "PERSONA_REFERENCED")
+ if _, ok := workflowStore.GetPersona("custom_writer"); !ok {
+ t.Fatal("referenced persona should remain")
+ }
+}
+
func TestCreatePersonaHandlerValidatesRequiredFieldsAndFormat(t *testing.T) {
workflowStore = memory.NewWorkflowStore()
tests := []struct {
diff --git a/internal/infrastructure/repository/memory/workflow.go b/internal/infrastructure/repository/memory/workflow.go
index 318cfa4..8f3ce0d 100644
--- a/internal/infrastructure/repository/memory/workflow.go
+++ b/internal/infrastructure/repository/memory/workflow.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"sync"
+ "time"
"github.com/teradakousuke/note_maker/internal/application/authorstyle"
authordomain "github.com/teradakousuke/note_maker/internal/domain/author"
@@ -23,14 +24,16 @@ type WorkflowStore struct {
guideIndexes map[string]authorstyle.AnalyzeResult
sessions map[string]briefdomain.ArticleBriefSession
briefs map[string]briefdomain.ArticleBrief
+ briefVersions map[string][]briefdomain.ArticleBriefVersion
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"`
+ AuthorStyles map[string]authorstyle.AnalyzeResult `json:"author_styles"`
+ Sessions map[string]briefdomain.ArticleBriefSession `json:"sessions"`
+ Briefs map[string]briefdomain.ArticleBrief `json:"briefs"`
+ BriefVersions map[string][]briefdomain.ArticleBriefVersion `json:"brief_versions,omitempty"`
+ Personas map[string]personadomain.Persona `json:"personas,omitempty"`
}
// NewWorkflowStore creates an empty local workflow store.
@@ -41,6 +44,7 @@ func NewWorkflowStore() *WorkflowStore {
guideIndexes: make(map[string]authorstyle.AnalyzeResult),
sessions: make(map[string]briefdomain.ArticleBriefSession),
briefs: make(map[string]briefdomain.ArticleBrief),
+ briefVersions: make(map[string][]briefdomain.ArticleBriefVersion),
personas: make(map[string]personadomain.Persona),
}
}
@@ -145,6 +149,7 @@ func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBri
}
s.mu.Lock()
defer s.mu.Unlock()
+ s.appendBriefVersionLocked(sessionID, brief)
s.briefs[sessionID] = brief
return s.persistLocked()
}
@@ -168,6 +173,27 @@ func (s *WorkflowStore) ListBriefs() (map[string]briefdomain.ArticleBrief, error
return briefs, nil
}
+// ListBriefVersions returns persisted brief revisions for a session.
+func (s *WorkflowStore) ListBriefVersions(sessionID string) ([]briefdomain.ArticleBriefVersion, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ versions := s.briefVersions[sessionID]
+ if len(versions) == 0 {
+ brief, ok := s.briefs[sessionID]
+ if !ok {
+ return []briefdomain.ArticleBriefVersion{}, nil
+ }
+ return []briefdomain.ArticleBriefVersion{{
+ SessionID: sessionID,
+ Version: 1,
+ Brief: brief,
+ }}, nil
+ }
+ result := make([]briefdomain.ArticleBriefVersion, len(versions))
+ copy(result, versions)
+ return result, nil
+}
+
// SavePersona stores a user-authored persona.
func (s *WorkflowStore) SavePersona(persona personadomain.Persona) error {
if err := persona.ValidateCustom(); err != nil {
@@ -201,6 +227,21 @@ func (s *WorkflowStore) ListPersonas() ([]personadomain.Persona, error) {
return personas, nil
}
+// DeletePersona removes an unreferenced user-authored persona.
+func (s *WorkflowStore) DeletePersona(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ id = personadomain.NormalizeID(id)
+ if _, ok := s.personas[id]; !ok {
+ return personadomain.ErrPersonaNotFound
+ }
+ if s.personaReferencedLocked(id) {
+ return personadomain.ErrPersonaReferenced
+ }
+ delete(s.personas, id)
+ return s.persistLocked()
+}
+
// 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)
@@ -227,6 +268,7 @@ func (s *WorkflowStore) load() error {
s.authorStyles = nonNilAuthorStyles(snapshot.AuthorStyles)
s.sessions = nonNilSessions(snapshot.Sessions)
s.briefs = nonNilBriefs(snapshot.Briefs)
+ s.briefVersions = nonNilBriefVersions(snapshot.BriefVersions)
s.personas = nonNilPersonas(snapshot.Personas)
s.rebuildIndexesLocked()
return nil
@@ -240,10 +282,11 @@ func (s *WorkflowStore) persistLocked() error {
return fmt.Errorf("create workflow store dir: %w", err)
}
snapshot := workflowSnapshot{
- AuthorStyles: s.authorStyles,
- Sessions: s.sessions,
- Briefs: s.briefs,
- Personas: s.personas,
+ AuthorStyles: s.authorStyles,
+ Sessions: s.sessions,
+ Briefs: s.briefs,
+ BriefVersions: s.briefVersions,
+ Personas: s.personas,
}
encoded, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
@@ -275,6 +318,48 @@ func (s *WorkflowStore) rebuildIndexesLocked() {
}
}
+func (s *WorkflowStore) appendBriefVersionLocked(sessionID string, brief briefdomain.ArticleBrief) {
+ versions := s.briefVersions[sessionID]
+ if len(versions) == 0 {
+ if previous, ok := s.briefs[sessionID]; ok {
+ versions = append(versions, briefdomain.ArticleBriefVersion{
+ SessionID: sessionID,
+ Version: 1,
+ Brief: previous,
+ CreatedAt: time.Now().UTC(),
+ })
+ }
+ }
+ versions = append(versions, briefdomain.ArticleBriefVersion{
+ SessionID: sessionID,
+ Version: len(versions) + 1,
+ Brief: brief,
+ CreatedAt: time.Now().UTC(),
+ })
+ s.briefVersions[sessionID] = versions
+}
+
+func (s *WorkflowStore) personaReferencedLocked(id string) bool {
+ for _, session := range s.sessions {
+ if session.PersonaID == id {
+ return true
+ }
+ }
+ for _, brief := range s.briefs {
+ if brief.PersonaID == id {
+ return true
+ }
+ }
+ for _, versions := range s.briefVersions {
+ for _, version := range versions {
+ if version.Brief.PersonaID == id {
+ return true
+ }
+ }
+ }
+ return false
+}
+
func nonNilAuthorStyles(values map[string]authorstyle.AnalyzeResult) map[string]authorstyle.AnalyzeResult {
if values == nil {
return make(map[string]authorstyle.AnalyzeResult)
@@ -296,6 +381,13 @@ func nonNilBriefs(values map[string]briefdomain.ArticleBrief) map[string]briefdo
return values
}
+func nonNilBriefVersions(values map[string][]briefdomain.ArticleBriefVersion) map[string][]briefdomain.ArticleBriefVersion {
+ if values == nil {
+ return make(map[string][]briefdomain.ArticleBriefVersion)
+ }
+ return values
+}
+
func nonNilPersonas(values map[string]personadomain.Persona) map[string]personadomain.Persona {
if values == nil {
return make(map[string]personadomain.Persona)
diff --git a/internal/infrastructure/repository/memory/workflow_test.go b/internal/infrastructure/repository/memory/workflow_test.go
index e558d98..8175829 100644
--- a/internal/infrastructure/repository/memory/workflow_test.go
+++ b/internal/infrastructure/repository/memory/workflow_test.go
@@ -32,6 +32,11 @@ func TestPersistentWorkflowStoreRestoresDraftInputs(t *testing.T) {
if err := store.SaveBrief(session.ID, brief); err != nil {
t.Fatalf("save brief: %v", err)
}
+ editedBrief := brief
+ editedBrief.Theme = "Edited local workflow brief"
+ if err := store.SaveBrief(session.ID, editedBrief); err != nil {
+ t.Fatalf("save edited brief: %v", err)
+ }
reopened, err := NewPersistentWorkflowStore(path)
if err != nil {
@@ -55,9 +60,16 @@ func TestPersistentWorkflowStoreRestoresDraftInputs(t *testing.T) {
if !ok {
t.Fatal("expected brief after reopen")
}
- if restoredBrief.PersonalContext == "" || len(restoredBrief.DeepDives) != 1 || len(restoredBrief.CustomAnswers) != 1 {
+ if restoredBrief.Theme != editedBrief.Theme || restoredBrief.PersonalContext == "" || len(restoredBrief.DeepDives) != 1 || len(restoredBrief.CustomAnswers) != 1 {
t.Fatalf("brief did not preserve generation context: %#v", restoredBrief)
}
+ versions, err := reopened.ListBriefVersions(session.ID)
+ if err != nil {
+ t.Fatalf("list brief versions: %v", err)
+ }
+ if len(versions) != 2 || versions[0].Brief.Theme == editedBrief.Theme || versions[1].Brief.Theme != editedBrief.Theme {
+ t.Fatalf("unexpected brief versions: %#v", versions)
+ }
}
func TestPersistentWorkflowStoreRestoresCustomPersonas(t *testing.T) {
@@ -91,6 +103,37 @@ func TestPersistentWorkflowStoreRestoresCustomPersonas(t *testing.T) {
}
}
+func TestWorkflowStoreDeletesOnlyUnreferencedCustomPersonas(t *testing.T) {
+ store := NewWorkflowStore()
+ persona := testCustomPersona()
+ if err := store.SavePersona(persona); err != nil {
+ t.Fatalf("save persona: %v", err)
+ }
+ if err := store.DeletePersona(persona.ID); err != nil {
+ t.Fatalf("delete unreferenced persona: %v", err)
+ }
+ if _, ok := store.GetPersona(persona.ID); ok {
+ t.Fatal("persona should be deleted")
+ }
+ if err := store.DeletePersona(persona.ID); err != personadomain.ErrPersonaNotFound {
+ t.Fatalf("delete missing persona err = %v, want ErrPersonaNotFound", err)
+ }
+
+ if err := store.SavePersona(persona); err != nil {
+ t.Fatalf("resave persona: %v", err)
+ }
+ session, err := briefdomain.NewArticleBriefSessionWithOptions("session_custom", "profile_custom", persona.ID, outputformat.IDNoteArticle, "", briefdomain.FixedQuestions())
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := store.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ if err := store.DeletePersona(persona.ID); err != personadomain.ErrPersonaReferenced {
+ t.Fatalf("delete referenced persona err = %v, want ErrPersonaReferenced", err)
+ }
+}
+
func testAnalyzeResult(t *testing.T) authorstyle.AnalyzeResult {
t.Helper()
fetchedAt := time.Unix(1700000000, 0).UTC()
diff --git a/internal/infrastructure/repository/sqlite/migrations/0003_brief_versions.sql b/internal/infrastructure/repository/sqlite/migrations/0003_brief_versions.sql
new file mode 100644
index 0000000..fdd27a1
--- /dev/null
+++ b/internal/infrastructure/repository/sqlite/migrations/0003_brief_versions.sql
@@ -0,0 +1,24 @@
+CREATE TABLE IF NOT EXISTS brief_versions (
+ session_id TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ style_profile_id TEXT NOT NULL,
+ persona_id TEXT NOT NULL DEFAULT '',
+ output_format_id TEXT NOT NULL DEFAULT '',
+ brief_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (session_id, version),
+ FOREIGN KEY (session_id) REFERENCES briefs(session_id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_brief_versions_session ON brief_versions(session_id, version);
+
+INSERT INTO brief_versions (
+ session_id, version, style_profile_id, persona_id, output_format_id, brief_json, created_at
+)
+SELECT session_id, 1, style_profile_id, persona_id, output_format_id, brief_json, created_at
+FROM briefs
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM brief_versions
+ WHERE brief_versions.session_id = briefs.session_id
+);
diff --git a/internal/infrastructure/repository/sqlite/workflow.go b/internal/infrastructure/repository/sqlite/workflow.go
index 2f92b4e..bfdb924 100644
--- a/internal/infrastructure/repository/sqlite/workflow.go
+++ b/internal/infrastructure/repository/sqlite/workflow.go
@@ -494,7 +494,36 @@ func (s *WorkflowStore) SaveBrief(sessionID string, brief briefdomain.ArticleBri
return fmt.Errorf("encode brief: %w", err)
}
now := nowUTC()
- _, err = s.db.Exec(`
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin save brief: %w", err)
+ }
+ defer rollbackUnlessDone(tx)
+ var previousStyleProfileID, previousPersonaID, previousOutputFormatID, previousBriefJSON, previousCreatedAt string
+ previousErr := tx.QueryRow(`
+SELECT style_profile_id, persona_id, output_format_id, brief_json, created_at
+FROM briefs
+WHERE session_id = ?`, sessionID).Scan(&previousStyleProfileID, &previousPersonaID, &previousOutputFormatID, &previousBriefJSON, &previousCreatedAt)
+ if previousErr != nil && previousErr != sql.ErrNoRows {
+ return fmt.Errorf("load previous brief: %w", previousErr)
+ }
+ var maxVersion int
+ if err := tx.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM brief_versions WHERE session_id = ?`, sessionID).Scan(&maxVersion); err != nil {
+ return fmt.Errorf("load brief version: %w", err)
+ }
+ if maxVersion == 0 && previousErr == nil {
+ _, err = tx.Exec(`
+INSERT INTO brief_versions (
+ session_id, version, style_profile_id, persona_id, output_format_id, brief_json, created_at
+)
+VALUES (?, 1, ?, ?, ?, ?, ?)`,
+ sessionID, previousStyleProfileID, previousPersonaID, previousOutputFormatID, previousBriefJSON, previousCreatedAt)
+ if err != nil {
+ return fmt.Errorf("seed previous brief version: %w", err)
+ }
+ maxVersion = 1
+ }
+ _, err = tx.Exec(`
INSERT INTO briefs (session_id, style_profile_id, persona_id, output_format_id, brief_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
@@ -507,6 +536,18 @@ ON CONFLICT(session_id) DO UPDATE SET
if err != nil {
return fmt.Errorf("save brief: %w", err)
}
+ _, err = tx.Exec(`
+INSERT INTO brief_versions (
+ session_id, version, style_profile_id, persona_id, output_format_id, brief_json, created_at
+)
+VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ sessionID, maxVersion+1, brief.StyleProfileID, brief.PersonaID, brief.OutputFormatID, briefJSON, formatTime(now))
+ if err != nil {
+ return fmt.Errorf("save brief version: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit save brief: %w", err)
+ }
return nil
}
@@ -552,6 +593,37 @@ ORDER BY updated_at DESC, session_id`)
return briefs, nil
}
+// ListBriefVersions returns persisted brief revisions for a session.
+func (s *WorkflowStore) ListBriefVersions(sessionID string) ([]briefdomain.ArticleBriefVersion, error) {
+ rows, err := s.db.Query(`
+SELECT version, brief_json, created_at
+FROM brief_versions
+WHERE session_id = ?
+ORDER BY version`, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("list brief versions: %w", err)
+ }
+ defer rows.Close()
+ var versions []briefdomain.ArticleBriefVersion
+ for rows.Next() {
+ var version briefdomain.ArticleBriefVersion
+ var briefJSON, createdAt string
+ if err := rows.Scan(&version.Version, &briefJSON, &createdAt); err != nil {
+ return nil, fmt.Errorf("scan brief version: %w", err)
+ }
+ if err := unmarshalString(briefJSON, &version.Brief); err != nil {
+ return nil, fmt.Errorf("decode brief version %d: %w", version.Version, err)
+ }
+ version.SessionID = sessionID
+ version.CreatedAt = parseTime(createdAt)
+ versions = append(versions, version)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate brief versions: %w", err)
+ }
+ return versions, nil
+}
+
// SavePersona stores a user-authored persona.
func (s *WorkflowStore) SavePersona(persona personadomain.Persona) error {
if err := persona.ValidateCustom(); err != nil {
@@ -622,6 +694,51 @@ ORDER BY created_at, id`)
return personas, nil
}
+// DeletePersona removes an unreferenced user-authored persona.
+func (s *WorkflowStore) DeletePersona(id string) error {
+ id = strings.TrimSpace(id)
+ var exists int
+ err := s.db.QueryRow(`SELECT 1 FROM custom_personas WHERE id = ?`, id).Scan(&exists)
+ if err == sql.ErrNoRows {
+ return personadomain.ErrPersonaNotFound
+ }
+ if err != nil {
+ return fmt.Errorf("check persona: %w", err)
+ }
+ referenced, err := s.personaReferenced(id)
+ if err != nil {
+ return err
+ }
+ if referenced {
+ return personadomain.ErrPersonaReferenced
+ }
+ if _, err := s.db.Exec(`DELETE FROM custom_personas WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("delete persona: %w", err)
+ }
+ return nil
+}
+
+func (s *WorkflowStore) personaReferenced(id string) (bool, error) {
+ checks := []string{
+ `SELECT 1 FROM brief_sessions WHERE persona_id = ? LIMIT 1`,
+ `SELECT 1 FROM briefs WHERE persona_id = ? LIMIT 1`,
+ `SELECT 1 FROM brief_versions WHERE persona_id = ? LIMIT 1`,
+ `SELECT 1 FROM articles WHERE persona_id = ? LIMIT 1`,
+ `SELECT 1 FROM drafts WHERE persona_id = ? LIMIT 1`,
+ }
+ for _, query := range checks {
+ var exists int
+ err := s.db.QueryRow(query, id).Scan(&exists)
+ if err == nil {
+ return true, nil
+ }
+ if err != sql.ErrNoRows {
+ return false, fmt.Errorf("check persona references: %w", err)
+ }
+ }
+ return false, 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 d61b529..9099f7d 100644
--- a/internal/infrastructure/repository/sqlite/workflow_test.go
+++ b/internal/infrastructure/repository/sqlite/workflow_test.go
@@ -35,6 +35,11 @@ func TestWorkflowStoreRestoresDraftInputs(t *testing.T) {
if err := store.SaveBrief(session.ID, brief); err != nil {
t.Fatalf("save brief: %v", err)
}
+ editedBrief := brief
+ editedBrief.Theme = "Edited SQLite workflow brief"
+ if err := store.SaveBrief(session.ID, editedBrief); err != nil {
+ t.Fatalf("save edited brief: %v", err)
+ }
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
@@ -67,9 +72,16 @@ func TestWorkflowStoreRestoresDraftInputs(t *testing.T) {
if !ok {
t.Fatal("expected brief after reopen")
}
- if restoredBrief.PersonalContext == "" || len(restoredBrief.DeepDives) != 1 || len(restoredBrief.CustomAnswers) != 1 {
+ if restoredBrief.Theme != editedBrief.Theme || restoredBrief.PersonalContext == "" || len(restoredBrief.DeepDives) != 1 || len(restoredBrief.CustomAnswers) != 1 {
t.Fatalf("brief did not preserve generation context: %#v", restoredBrief)
}
+ versions, err := reopened.ListBriefVersions(session.ID)
+ if err != nil {
+ t.Fatalf("list brief versions: %v", err)
+ }
+ if len(versions) != 2 || versions[0].Brief.Theme == editedBrief.Theme || versions[1].Brief.Theme != editedBrief.Theme {
+ t.Fatalf("unexpected brief versions: %#v", versions)
+ }
}
func TestWorkflowStoreRestoresCustomPersonas(t *testing.T) {
@@ -107,6 +119,41 @@ func TestWorkflowStoreRestoresCustomPersonas(t *testing.T) {
}
}
+func TestWorkflowStoreDeletesOnlyUnreferencedCustomPersonas(t *testing.T) {
+ store, err := NewWorkflowStore(filepath.Join(t.TempDir(), "note_maker.db"))
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ persona := testCustomPersona()
+ if err := store.SavePersona(persona); err != nil {
+ t.Fatalf("save persona: %v", err)
+ }
+ if err := store.DeletePersona(persona.ID); err != nil {
+ t.Fatalf("delete unreferenced persona: %v", err)
+ }
+ if _, ok := store.GetPersona(persona.ID); ok {
+ t.Fatal("persona should be deleted")
+ }
+ if err := store.DeletePersona(persona.ID); err != personadomain.ErrPersonaNotFound {
+ t.Fatalf("delete missing persona err = %v, want ErrPersonaNotFound", err)
+ }
+
+ if err := store.SavePersona(persona); err != nil {
+ t.Fatalf("resave persona: %v", err)
+ }
+ session, err := briefdomain.NewArticleBriefSessionWithOptions("session_custom", "profile_custom", persona.ID, outputformat.IDNoteArticle, "", briefdomain.FixedQuestions())
+ if err != nil {
+ t.Fatalf("new session: %v", err)
+ }
+ if err := store.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ if err := store.DeletePersona(persona.ID); err != personadomain.ErrPersonaReferenced {
+ t.Fatalf("delete referenced persona err = %v, want ErrPersonaReferenced", err)
+ }
+}
+
func TestWorkflowStoreAppliesSchemaMigrations(t *testing.T) {
store, err := NewWorkflowStore(filepath.Join(t.TempDir(), "note_maker.db"))
if err != nil {
@@ -121,7 +168,13 @@ 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", "briefs", "custom_personas", "drafts", "section_regenerations", "source_selector_snapshots"} {
+ if err := store.DB().QueryRow(`SELECT count(*) FROM schema_migrations WHERE version = 3`).Scan(&migrationCount); err != nil {
+ t.Fatalf("query brief version migration: %v", err)
+ }
+ if migrationCount != 1 {
+ t.Fatalf("brief version migration count = %d, want 1", migrationCount)
+ }
+ for _, table := range []string{"projects", "articles", "brief_sessions", "brief_answers", "briefs", "brief_versions", "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)
@@ -129,6 +182,43 @@ func TestWorkflowStoreAppliesSchemaMigrations(t *testing.T) {
}
}
+func TestBriefVersionsMigrationBackfillIsIdempotent(t *testing.T) {
+ store, err := NewWorkflowStore(filepath.Join(t.TempDir(), "note_maker.db"))
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+
+ session := testCompletedSession(t, "profile-idempotent")
+ if err := store.SaveSession(session); err != nil {
+ t.Fatalf("save session: %v", err)
+ }
+ brief := session.AssembleBrief()
+ if err := store.SaveBrief(session.ID, brief); err != nil {
+ t.Fatalf("save brief: %v", err)
+ }
+ if _, err := store.DB().Exec(`DELETE FROM brief_versions WHERE session_id = ?`, session.ID); err != nil {
+ t.Fatalf("clear brief versions: %v", err)
+ }
+
+ migration, err := migrationFiles.ReadFile("migrations/0003_brief_versions.sql")
+ if err != nil {
+ t.Fatalf("read brief versions migration: %v", err)
+ }
+ for i := 0; i < 2; i++ {
+ if _, err := store.DB().Exec(string(migration)); err != nil {
+ t.Fatalf("rerun brief versions migration %d: %v", i+1, err)
+ }
+ }
+ var count int
+ if err := store.DB().QueryRow(`SELECT count(*) FROM brief_versions WHERE session_id = ?`, session.ID).Scan(&count); err != nil {
+ t.Fatalf("count brief versions: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("backfilled version count = %d, want 1", count)
+ }
+}
+
func TestWorkflowStorePersistsHistoryRecords(t *testing.T) {
path := filepath.Join(t.TempDir(), "note_maker.db")
store, err := NewWorkflowStore(path)
diff --git a/mise.toml b/mise.toml
index 71cb5f0..2574cb7 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,5 +1,17 @@
+[tasks.launcher]
+description = "Start the app-like launcher against Evo X2 Tailnet"
+run = "make launcher"
+
+[tasks.launcher-status]
+description = "Check launcher storage, port, and LLM health without starting the app"
+run = "make launcher-status"
+
+[tasks.launcher-check]
+description = "Run launcher syntax checks and shell tests"
+run = "make launcher-check"
+
[tasks.dev]
-description = "Start local llama.cpp and the Note Maker web app"
+description = "Start the legacy development script"
run = "make dev"
[tasks.evo-x2]
@@ -10,6 +22,10 @@ run = "make evo-x2"
description = "Run the full 3000-character scenario against Ollama on Evo X2"
run = "make scenario-evo-x2"
+[tasks.scenario-local-llamacpp-fallback]
+description = "Plan or explicitly run local llama.cpp fallback validation"
+run = "make scenario-local-llamacpp-fallback"
+
[tasks.check]
description = "Run Go tests"
run = "make check"
diff --git a/scripts/check-launcher.sh b/scripts/check-launcher.sh
new file mode 100755
index 0000000..7460a61
--- /dev/null
+++ b/scripts/check-launcher.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+scripts=(
+ "scripts/dev.sh"
+ "scripts/evo-x2-tailnet-preflight.sh"
+ "scripts/evo-x2-ssh-preflight.sh"
+ "scripts/launcher.sh"
+ "scripts/launcher_test.sh"
+ "scripts/check-launcher.sh"
+)
+
+for script in "${scripts[@]}"; do
+ bash -n "${ROOT}/${script}"
+done
+
+if command -v shellcheck >/dev/null 2>&1; then
+ shellcheck "${scripts[@]/#/${ROOT}/}"
+else
+ printf 'shellcheck not found; skipped\n'
+fi
+
+"${ROOT}/scripts/launcher_test.sh"
+make -C "$ROOT" -n launcher >/dev/null
+make -C "$ROOT" -n launcher-status >/dev/null
+
+printf 'launcher checks passed\n'
diff --git a/scripts/evo-x2-llama-swap.sh b/scripts/evo-x2-llama-swap.sh
new file mode 100755
index 0000000..04da154
--- /dev/null
+++ b/scripts/evo-x2-llama-swap.sh
@@ -0,0 +1,195 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/evo-x2-llama-swap.sh [inspect|plan|start|swap]
+
+Dry-run is the default. Mutating remote actions require:
+
+ EVO_X2_LLAMA_CPP_APPLY=1
+
+Restarting the shared llama.cpp service for a profile swap also requires:
+
+ EVO_X2_LLAMA_CPP_ALLOW_RESTART=1
+
+Environment:
+ EVO_X2_TAILNET_HOST Tailnet host, default evo-x2.tailb30e58.ts.net
+ EVO_X2_SSH_HOST SSH host for systemd operations, default evo-x2
+ EVO_X2_OLLAMA_LLM_BASE_URL Ollama primary OpenAI URL
+ EVO_X2_LLAMA_CPP_LLM_BASE_URL llama.cpp fallback OpenAI URL
+ EVO_X2_LLAMA_CPP_SERVICE systemd service, default note-maker-llama-cpp.service
+ EVO_X2_LLAMA_CPP_PROFILE profile name, default fallback-gemma-e2b
+ EVO_X2_LLAMA_CPP_PROFILE_ENV profile env file on Evo X2
+ EVO_X2_LLAMA_CPP_ACTIVE_ENV active env symlink on Evo X2
+ EVO_X2_LLAMA_CPP_HEALTH_PATH health path, default /health
+EOF
+}
+
+action="${1:-inspect}"
+case "$action" in
+ inspect|plan|start|swap)
+ ;;
+ -h|--help|help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown action: ${action}" >&2
+ usage >&2
+ exit 2
+ ;;
+esac
+
+tailnet_host="${EVO_X2_TAILNET_HOST:-evo-x2.tailb30e58.ts.net}"
+ssh_host="${EVO_X2_SSH_HOST:-evo-x2}"
+ssh_bin="${EVO_X2_SSH_BIN:-ssh}"
+timeout="${EVO_X2_PREFLIGHT_TIMEOUT_SECONDS:-5}"
+ollama_base_url="${EVO_X2_OLLAMA_LLM_BASE_URL:-http://${tailnet_host}/v1}"
+llama_base_url="${EVO_X2_LLAMA_CPP_LLM_BASE_URL:-http://${tailnet_host}/llama/v1}"
+service="${EVO_X2_LLAMA_CPP_SERVICE:-note-maker-llama-cpp.service}"
+profile="${EVO_X2_LLAMA_CPP_PROFILE:-fallback-gemma-e2b}"
+profile_env="${EVO_X2_LLAMA_CPP_PROFILE_ENV:-/etc/note-maker/llama-cpp/${profile}.env}"
+active_env="${EVO_X2_LLAMA_CPP_ACTIVE_ENV:-/etc/note-maker/llama-cpp/active.env}"
+health_path="${EVO_X2_LLAMA_CPP_HEALTH_PATH:-/health}"
+apply="${EVO_X2_LLAMA_CPP_APPLY:-0}"
+allow_restart="${EVO_X2_LLAMA_CPP_ALLOW_RESTART:-0}"
+
+strip_v1() {
+ local value="$1"
+ value="${value%/}"
+ printf '%s\n' "${value%/v1}"
+}
+
+curl_json() {
+ local url="$1"
+ if command -v jq >/dev/null 2>&1; then
+ curl -fsS --max-time "$timeout" "$url" | jq .
+ else
+ curl -fsS --max-time "$timeout" "$url"
+ printf '\n'
+ fi
+}
+
+print_models() {
+ local label="$1"
+ local base_url="$2"
+ local models_url="${base_url%/}/models"
+ echo "== ${label}: ${models_url}"
+ if ! curl_json "$models_url"; then
+ echo "${label} models endpoint is not reachable" >&2
+ return 1
+ fi
+}
+
+run_ssh() {
+ if ! command -v "$ssh_bin" >/dev/null 2>&1; then
+ echo "${ssh_bin} was not found; remote systemd operation cannot run" >&2
+ return 127
+ fi
+ "$ssh_bin" -o BatchMode=yes -o ConnectTimeout="$timeout" "$ssh_host" "$@"
+}
+
+remote_quote() {
+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
+}
+
+remote_command() {
+ local command_action="${1:-$action}"
+ local quoted_profile_env quoted_active_env quoted_service
+ quoted_profile_env="$(remote_quote "$profile_env")"
+ quoted_active_env="$(remote_quote "$active_env")"
+ quoted_service="$(remote_quote "$service")"
+
+ case "$command_action" in
+ start)
+ printf 'sudo systemctl start %s\n' "$quoted_service"
+ ;;
+ swap)
+ printf 'sudo ln -sfn %s %s && sudo systemctl restart %s\n' "$quoted_profile_env" "$quoted_active_env" "$quoted_service"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+print_strategy() {
+ cat </dev/null || true; systemctl --no-pager --full status $(remote_quote "$service") 2>/dev/null | sed -n '1,18p' || true"; then
+ echo "Remote systemd status unavailable; API inspection above is still authoritative for clients." >&2
+ fi
+}
+
+if [ "$ollama_base_url" = "$llama_base_url" ]; then
+ echo "Refusing to operate: Ollama and llama.cpp URLs are identical (${ollama_base_url})." >&2
+ exit 2
+fi
+
+if [ "$action" = "inspect" ]; then
+ print_models "Ollama primary" "$ollama_base_url" || true
+ echo
+ print_models "llama.cpp fallback" "$llama_base_url" || true
+ echo
+ inspect_remote_systemd
+ exit 0
+fi
+
+print_strategy
+
+if [ "$action" = "plan" ]; then
+ echo
+ echo "Dry-run remote commands:"
+ echo " start: $(remote_command start)"
+ echo " swap: $(remote_command swap)"
+ echo
+ echo "Post-checks:"
+ echo " curl -fsS $(strip_v1 "$llama_base_url")${health_path}"
+ echo " curl -fsS ${llama_base_url%/}/models"
+ exit 0
+fi
+
+command_text="$(remote_command)"
+echo
+echo "Remote command:"
+echo " ${command_text}"
+
+if [ "$apply" != "1" ]; then
+ echo
+ echo "Dry-run only. Set EVO_X2_LLAMA_CPP_APPLY=1 to run this on ${ssh_host}."
+ exit 0
+fi
+
+if [ "$action" = "swap" ] && [ "$allow_restart" != "1" ]; then
+ echo "Refusing swap without EVO_X2_LLAMA_CPP_ALLOW_RESTART=1." >&2
+ exit 2
+fi
+
+run_ssh "$command_text"
+
+echo
+echo "Waiting for llama.cpp health..."
+sleep 2
+curl_json "$(strip_v1 "$llama_base_url")${health_path}" || true
+echo
+print_models "llama.cpp fallback" "$llama_base_url"
diff --git a/scripts/launcher.sh b/scripts/launcher.sh
new file mode 100755
index 0000000..7c84c1d
--- /dev/null
+++ b/scripts/launcher.sh
@@ -0,0 +1,502 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+NM_APP_PID=""
+NM_LLAMA_PID=""
+
+nm_repo_root() {
+ cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P
+}
+
+nm_log() {
+ printf '[note-maker] %s\n' "$*" >&2
+}
+
+nm_warn() {
+ printf '[note-maker] WARN: %s\n' "$*" >&2
+}
+
+nm_die() {
+ printf '[note-maker] ERROR: %s\n' "$*" >&2
+ exit 1
+}
+
+nm_usage() {
+ cat <<'EOF'
+Usage: scripts/launcher.sh [options]
+
+Starts Note Maker as an app-like local launcher.
+
+Options:
+ --port PORT Preferred app port. The launcher selects the next free
+ port unless --strict-port is also set.
+ --strict-port Fail instead of selecting the next free port.
+ --data-dir PATH User data directory for config, workflow store, and logs.
+ --no-open Do not open the app URL in the browser.
+ --allow-degraded Start the app even when Evo X2 Tailnet health fails.
+ --local-llm Explicitly start local llama.cpp and use it as primary.
+ --status Print launcher health/config status without starting.
+ -h, --help Show this help.
+
+Environment:
+ NOTE_MAKER_DATA_DIR Overrides the default app data directory.
+ NOTE_MAKER_OPEN_BROWSER=0 Same as --no-open.
+ NOTE_MAKER_REQUIRE_EVO_X2=0 Same as --allow-degraded.
+ NOTE_MAKER_START_LOCAL_LLM=1 Same as --local-llm.
+EOF
+}
+
+nm_load_env() {
+ local root="$1"
+ if [ "${NOTE_MAKER_SKIP_ENV:-0}" != "1" ] && [ -f "${root}/.env" ]; then
+ set -a
+ # shellcheck disable=SC1091
+ source "${root}/.env"
+ set +a
+ fi
+}
+
+nm_default_data_dir() {
+ if [ -n "${NOTE_MAKER_DATA_DIR:-}" ]; then
+ printf '%s\n' "$NOTE_MAKER_DATA_DIR"
+ return 0
+ fi
+
+ local home="${HOME:-}"
+ if [ -z "$home" ]; then
+ printf '%s\n' "$(nm_repo_root)/data"
+ return 0
+ fi
+
+ case "$(uname -s)" in
+ Darwin)
+ printf '%s\n' "${home}/Library/Application Support/Note Maker"
+ ;;
+ *)
+ if [ -n "${XDG_DATA_HOME:-}" ]; then
+ printf '%s\n' "${XDG_DATA_HOME}/note-maker"
+ else
+ printf '%s\n' "${home}/.local/share/note-maker"
+ fi
+ ;;
+ esac
+}
+
+nm_port_available() {
+ local port="$1"
+
+ if command -v python3 >/dev/null 2>&1; then
+ python3 - "$port" <<'PY' >/dev/null 2>&1
+import socket
+import sys
+
+port = int(sys.argv[1])
+sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+try:
+ sock.bind(("127.0.0.1", port))
+except OSError:
+ sys.exit(1)
+finally:
+ sock.close()
+PY
+ return $?
+ fi
+
+ if command -v lsof >/dev/null 2>&1; then
+ if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
+ return 1
+ fi
+ return 0
+ fi
+
+ nm_warn "python3 and lsof are unavailable; assuming port ${port} is free"
+ return 0
+}
+
+nm_find_available_port() {
+ local requested="$1"
+ local strict="$2"
+ local port="$requested"
+ local attempts=50
+
+ if [ "$strict" = "1" ]; then
+ if nm_port_available "$port"; then
+ printf '%s\n' "$port"
+ return 0
+ fi
+ return 1
+ fi
+
+ while [ "$attempts" -gt 0 ]; do
+ if nm_port_available "$port"; then
+ printf '%s\n' "$port"
+ return 0
+ fi
+ port=$((port + 1))
+ attempts=$((attempts - 1))
+ done
+
+ return 1
+}
+
+nm_models_url() {
+ local base_url="$1"
+ printf '%s/models\n' "${base_url%/}"
+}
+
+nm_http_ok() {
+ local url="$1"
+ local timeout="$2"
+
+ if ! command -v curl >/dev/null 2>&1; then
+ return 1
+ fi
+
+ curl -fsS --max-time "$timeout" "$url" >/dev/null 2>&1
+}
+
+nm_print_llm_health() {
+ local label="$1"
+ local base_url="$2"
+ local timeout="$3"
+ local models_url
+ models_url="$(nm_models_url "$base_url")"
+
+ if nm_http_ok "$models_url" "$timeout"; then
+ nm_log "${label}: ready (${models_url})"
+ return 0
+ fi
+
+ nm_warn "${label}: not reachable (${models_url})"
+ return 1
+}
+
+nm_wait_http() {
+ local label="$1"
+ local url="$2"
+ local timeout="$3"
+ local start="$SECONDS"
+
+ while [ $((SECONDS - start)) -lt "$timeout" ]; do
+ if nm_http_ok "$url" 2; then
+ nm_log "${label}: ready (${url})"
+ return 0
+ fi
+ sleep 1
+ done
+
+ return 1
+}
+
+nm_open_browser() {
+ local url="$1"
+
+ case "$(uname -s)" in
+ Darwin)
+ open "$url" >/dev/null 2>&1 || nm_warn "Could not open browser; visit ${url}"
+ ;;
+ Linux)
+ if command -v xdg-open >/dev/null 2>&1; then
+ xdg-open "$url" >/dev/null 2>&1 || nm_warn "Could not open browser; visit ${url}"
+ else
+ nm_log "Open ${url}"
+ fi
+ ;;
+ *)
+ nm_log "Open ${url}"
+ ;;
+ esac
+}
+
+nm_stop_pid() {
+ local pid="$1"
+ local label="$2"
+ local attempts=20
+
+ if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
+ return 0
+ fi
+
+ nm_log "Stopping ${label} (pid ${pid})"
+ kill "$pid" 2>/dev/null || true
+
+ while [ "$attempts" -gt 0 ]; do
+ if ! kill -0 "$pid" 2>/dev/null; then
+ wait "$pid" 2>/dev/null || true
+ return 0
+ fi
+ sleep 1
+ attempts=$((attempts - 1))
+ done
+
+ nm_warn "${label} did not stop after SIGTERM; sending SIGKILL"
+ kill -9 "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+}
+
+nm_cleanup() {
+ nm_stop_pid "${NM_APP_PID:-}" "Note Maker server"
+ nm_stop_pid "${NM_LLAMA_PID:-}" "local llama.cpp"
+ NM_APP_PID=""
+ NM_LLAMA_PID=""
+}
+
+nm_start_local_llm() {
+ local log_file="$1"
+
+ if ! command -v "$LLAMA_SERVER" >/dev/null 2>&1; then
+ nm_die "llama-server was not found. Set LLAMA_SERVER=/path/to/llama-server or install llama.cpp."
+ fi
+
+ nm_log "Starting local llama.cpp on ${LLAMACPP_HOST}:${LLAMACPP_PORT}; log: ${log_file}"
+ "$LLAMA_SERVER" \
+ --hf-repo "$LLAMACPP_HF_REPO" \
+ --hf-file "$LLAMACPP_HF_FILE" \
+ --alias "$LLAMACPP_MODEL" \
+ --host "$LLAMACPP_HOST" \
+ --port "$LLAMACPP_PORT" \
+ >"$log_file" 2>&1 &
+ printf '%s\n' "$!"
+}
+
+nm_build_server() {
+ local root="$1"
+ local bin_dir="$2"
+ local bin_path="${bin_dir}/note-maker-server"
+
+ mkdir -p "$bin_dir"
+ nm_log "Building server binary: ${bin_path}"
+ (cd "$root" && go build -o "$bin_path" ./cmd/server)
+ printf '%s\n' "$bin_path"
+}
+
+nm_start_server() {
+ local root="$1"
+ local bin_path="$2"
+ local log_file="$3"
+
+ nm_log "Starting Note Maker server; log: ${log_file}"
+ (
+ cd "$root"
+ exec "$bin_path"
+ ) >"$log_file" 2>&1 &
+ printf '%s\n' "$!"
+}
+
+nm_split_and_report_fallbacks() {
+ local fallbacks="$1"
+ local timeout="$2"
+ local item
+ local old_ifs="$IFS"
+
+ if [ -z "$fallbacks" ]; then
+ return 0
+ fi
+
+ IFS=','
+ for item in $fallbacks; do
+ item="${item#"${item%%[![:space:]]*}"}"
+ item="${item%"${item##*[![:space:]]}"}"
+ if [ -n "$item" ]; then
+ nm_print_llm_health "Fallback LLM" "$item" "$timeout" || true
+ fi
+ done
+ IFS="$old_ifs"
+}
+
+nm_main() {
+ local root
+ root="$(nm_repo_root)"
+
+ local requested_port=""
+ local strict_port="${NOTE_MAKER_STRICT_PORT:-0}"
+ local open_browser="${NOTE_MAKER_OPEN_BROWSER:-1}"
+ local require_evo_x2="${NOTE_MAKER_REQUIRE_EVO_X2:-1}"
+ local local_llm="${NOTE_MAKER_START_LOCAL_LLM:-0}"
+ local status_only=0
+ local data_dir_arg=""
+
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --port)
+ [ "$#" -ge 2 ] || nm_die "--port requires a value"
+ requested_port="$2"
+ shift 2
+ ;;
+ --strict-port)
+ strict_port=1
+ shift
+ ;;
+ --data-dir)
+ [ "$#" -ge 2 ] || nm_die "--data-dir requires a value"
+ data_dir_arg="$2"
+ shift 2
+ ;;
+ --no-open)
+ open_browser=0
+ shift
+ ;;
+ --allow-degraded)
+ require_evo_x2=0
+ shift
+ ;;
+ --local-llm)
+ local_llm=1
+ shift
+ ;;
+ --status)
+ status_only=1
+ open_browser=0
+ require_evo_x2=0
+ shift
+ ;;
+ -h|--help)
+ nm_usage
+ return 0
+ ;;
+ *)
+ nm_die "unknown option: $1"
+ ;;
+ esac
+ done
+
+ nm_load_env "$root"
+
+ if [ -n "$data_dir_arg" ]; then
+ NOTE_MAKER_DATA_DIR="$data_dir_arg"
+ fi
+
+ local data_dir
+ data_dir="$(nm_default_data_dir)"
+ mkdir -p "$data_dir" "${data_dir}/logs"
+
+ PORT="${requested_port:-${PORT:-8080}}"
+ if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
+ nm_die "PORT must be a number from 1 to 65535; got ${PORT}"
+ fi
+
+ local selected_port
+ if ! selected_port="$(nm_find_available_port "$PORT" "$strict_port")"; then
+ nm_die "port ${PORT} is not available"
+ fi
+ if [ "$selected_port" != "$PORT" ]; then
+ nm_warn "Port ${PORT} is busy; selected ${selected_port}"
+ fi
+ PORT="$selected_port"
+
+ EVO_X2_TAILNET_HOST="${EVO_X2_TAILNET_HOST:-evo-x2.tailb30e58.ts.net}"
+ EVO_X2_OLLAMA_LLM_BASE_URL="${EVO_X2_OLLAMA_LLM_BASE_URL:-http://${EVO_X2_TAILNET_HOST}/v1}"
+ EVO_X2_LLAMA_CPP_LLM_BASE_URL="${EVO_X2_LLAMA_CPP_LLM_BASE_URL:-http://${EVO_X2_TAILNET_HOST}/llama/v1}"
+ EVO_X2_LLM_BASE_URL="${EVO_X2_LLM_BASE_URL:-${EVO_X2_OLLAMA_LLM_BASE_URL}}"
+ LLAMACPP_HOST="${LLAMACPP_HOST:-127.0.0.1}"
+ LLAMACPP_PORT="${LLAMACPP_PORT:-8081}"
+ LLAMACPP_MODEL="${LLAMACPP_MODEL:-gemma4:31b}"
+ LLAMACPP_HF_REPO="${LLAMACPP_HF_REPO:-ggml-org/gemma-4-31B-it-GGUF}"
+ LLAMACPP_HF_FILE="${LLAMACPP_HF_FILE:-gemma-4-31B-it-Q4_K_M.gguf}"
+ LLAMA_SERVER="${LLAMA_SERVER:-llama-server}"
+
+ if [ "$local_llm" = "1" ]; then
+ LLM_RUNTIME="local"
+ LLM_BASE_URL="${NOTE_MAKER_LOCAL_LLM_BASE_URL:-http://${LLAMACPP_HOST}:${LLAMACPP_PORT}/v1}"
+ else
+ if [ "${LLM_RUNTIME:-}" = "local" ]; then
+ nm_warn "LLM_RUNTIME=local is set, but local llama.cpp starts only with --local-llm or NOTE_MAKER_START_LOCAL_LLM=1"
+ fi
+ LLM_RUNTIME="remote"
+ LLM_BASE_URL="${LLM_BASE_URL:-${EVO_X2_LLM_BASE_URL}}"
+ fi
+
+ LLM_MODEL="${LLM_MODEL:-${LLAMACPP_MODEL}}"
+ STYLE_LLM_MODEL="${STYLE_LLM_MODEL:-gemma4:e2b}"
+ BRIEF_LLM_MODEL="${BRIEF_LLM_MODEL:-qwen3.6:27b}"
+ ARTICLE_LLM_MODEL="${ARTICLE_LLM_MODEL:-gemma4:e2b}"
+ DRAFT_LLM_MODEL="${DRAFT_LLM_MODEL:-${LLM_MODEL}}"
+ VERIFY_LLM_MODEL="${VERIFY_LLM_MODEL:-gemma4:latest}"
+ LLM_FALLBACK_BASE_URLS="${LLM_FALLBACK_BASE_URLS:-${EVO_X2_LLAMA_CPP_LLM_BASE_URL},http://${LLAMACPP_HOST}:${LLAMACPP_PORT}/v1}"
+ STYLE_LLM_FALLBACK_MODELS="${STYLE_LLM_FALLBACK_MODELS:-}"
+ BRIEF_LLM_FALLBACK_MODELS="${BRIEF_LLM_FALLBACK_MODELS:-}"
+ ARTICLE_LLM_FALLBACK_MODELS="${ARTICLE_LLM_FALLBACK_MODELS:-}"
+ DRAFT_LLM_FALLBACK_MODELS="${DRAFT_LLM_FALLBACK_MODELS:-}"
+ VERIFY_LLM_FALLBACK_MODELS="${VERIFY_LLM_FALLBACK_MODELS:-}"
+ LLAMACPP_BASE_URL="${LLAMACPP_BASE_URL:-${LLM_BASE_URL}}"
+
+ NOTE_MAKER_CONFIG_PATH="${NOTE_MAKER_CONFIG_PATH:-${data_dir}/app_config.json}"
+ WORKFLOW_STORE_PATH="${WORKFLOW_STORE_PATH:-${data_dir}/workflow_store.json}"
+
+ export PORT LLM_RUNTIME LLM_BASE_URL LLM_MODEL
+ export STYLE_LLM_MODEL BRIEF_LLM_MODEL ARTICLE_LLM_MODEL DRAFT_LLM_MODEL VERIFY_LLM_MODEL
+ export LLM_FALLBACK_BASE_URLS STYLE_LLM_FALLBACK_MODELS BRIEF_LLM_FALLBACK_MODELS
+ export ARTICLE_LLM_FALLBACK_MODELS DRAFT_LLM_FALLBACK_MODELS VERIFY_LLM_FALLBACK_MODELS
+ export LLAMACPP_BASE_URL LLAMACPP_MODEL NOTE_MAKER_CONFIG_PATH WORKFLOW_STORE_PATH
+
+ nm_log "Data directory: ${data_dir}"
+ nm_log "Config path: ${NOTE_MAKER_CONFIG_PATH}"
+ nm_log "Workflow store path: ${WORKFLOW_STORE_PATH}"
+ nm_log "App port: ${PORT}"
+ nm_log "Primary LLM runtime: ${LLM_RUNTIME} (${LLM_BASE_URL}, model ${LLM_MODEL})"
+
+ local health_timeout="${NOTE_MAKER_HEALTH_TIMEOUT_SECONDS:-5}"
+ local evo_ready=0
+ if nm_print_llm_health "Evo X2 Tailnet primary" "$EVO_X2_LLM_BASE_URL" "$health_timeout"; then
+ evo_ready=1
+ fi
+ nm_split_and_report_fallbacks "$LLM_FALLBACK_BASE_URLS" "$health_timeout"
+
+ if [ "$local_llm" != "1" ] && [ "$require_evo_x2" = "1" ] && [ "$evo_ready" != "1" ]; then
+ cat >&2 </dev/null; do
+ if [ -n "$NM_LLAMA_PID" ] && ! kill -0 "$NM_LLAMA_PID" 2>/dev/null; then
+ nm_warn "local llama.cpp exited; stopping app"
+ break
+ fi
+ sleep 2
+ done
+}
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ nm_main "$@"
+fi
diff --git a/scripts/launcher_test.sh b/scripts/launcher_test.sh
new file mode 100755
index 0000000..22b3a5e
--- /dev/null
+++ b/scripts/launcher_test.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+# shellcheck source=scripts/launcher.sh
+source "${ROOT}/scripts/launcher.sh"
+
+fail() {
+ printf 'launcher_test: %s\n' "$*" >&2
+ exit 1
+}
+
+assert_eq() {
+ local got="$1"
+ local want="$2"
+ local label="$3"
+ if [ "$got" != "$want" ]; then
+ fail "${label}: got ${got}, want ${want}"
+ fi
+}
+
+tmp_dir="$(mktemp -d)"
+cleanup() {
+ rm -rf "$tmp_dir"
+ if [ -n "${busy_pid:-}" ]; then
+ kill "$busy_pid" 2>/dev/null || true
+ wait "$busy_pid" 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT
+
+NOTE_MAKER_DATA_DIR="${tmp_dir}/custom-data"
+assert_eq "$(nm_default_data_dir)" "${tmp_dir}/custom-data" "NOTE_MAKER_DATA_DIR override"
+unset NOTE_MAKER_DATA_DIR
+
+HOME="${tmp_dir}/home"
+mkdir -p "$HOME"
+unset XDG_DATA_HOME
+case "$(uname -s)" in
+ Darwin)
+ assert_eq "$(nm_default_data_dir)" "${HOME}/Library/Application Support/Note Maker" "macOS default data dir"
+ ;;
+ *)
+ assert_eq "$(nm_default_data_dir)" "${HOME}/.local/share/note-maker" "XDG fallback data dir"
+ XDG_DATA_HOME="${tmp_dir}/xdg"
+ assert_eq "$(nm_default_data_dir)" "${XDG_DATA_HOME}/note-maker" "XDG_DATA_HOME data dir"
+ ;;
+esac
+
+assert_eq "$(nm_models_url "http://example.test/v1/")" "http://example.test/v1/models" "models URL normalization"
+
+if command -v python3 >/dev/null 2>&1; then
+ port_file="${tmp_dir}/busy-port"
+ python3 - "$port_file" <<'PY' &
+import socket
+import sys
+import time
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+sock.bind(("127.0.0.1", 0))
+sock.listen()
+with open(sys.argv[1], "w", encoding="utf-8") as handle:
+ handle.write(str(sock.getsockname()[1]))
+ handle.flush()
+time.sleep(30)
+PY
+ busy_pid="$!"
+ for _ in 1 2 3 4 5; do
+ [ -s "$port_file" ] && break
+ sleep 1
+ done
+ [ -s "$port_file" ] || fail "busy port helper did not start"
+ busy_port="$(cat "$port_file")"
+ selected_port="$(nm_find_available_port "$busy_port" 0)"
+ if [ "$selected_port" = "$busy_port" ]; then
+ fail "port selection reused busy port ${busy_port}"
+ fi
+ if nm_find_available_port "$busy_port" 1 >/dev/null 2>&1; then
+ fail "strict port mode accepted busy port ${busy_port}"
+ fi
+fi
+
+printf 'launcher_test: ok\n'
diff --git a/static/css/style.css b/static/css/style.css
index 096d8ae..373c9df 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -130,7 +130,7 @@ body {
.inline-select-action {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
align-items: center;
}
@@ -402,6 +402,12 @@ button:disabled {
border: 1px solid var(--line);
}
+.danger-btn {
+ color: var(--danger);
+ background: var(--danger-bg);
+ border-color: #fecdca;
+}
+
.button-row {
display: flex;
flex-wrap: wrap;
@@ -470,6 +476,13 @@ pre {
gap: 12px;
}
+.artifact-card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
.artifact-card-header strong {
line-height: 1.35;
}
@@ -513,6 +526,37 @@ pre {
overflow-wrap: anywhere;
}
+.artifact-subheader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.brief-version-history {
+ padding-top: 10px;
+ border-top: 1px solid var(--line);
+}
+
+.brief-version-list {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding-left: 22px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.inline-status {
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.inline-status.warning {
+ color: var(--warning);
+}
+
.artifact-edit-form {
display: grid;
gap: 12px;
diff --git a/static/history_ui_test.go b/static/history_ui_test.go
index 032f916..44de5d9 100644
--- a/static/history_ui_test.go
+++ b/static/history_ui_test.go
@@ -72,7 +72,10 @@ func TestPersonaAuthoringContract(t *testing.T) {
for _, selector := range []string{
"#add-persona-btn",
+ "#edit-persona-btn",
+ "#delete-persona-btn",
"#add-persona-form",
+ "#persona-form-title",
"#persona-id-input",
"#persona-display-name-input",
"#persona-default-format-select",
@@ -96,13 +99,27 @@ func TestPersonaAuthoringContract(t *testing.T) {
assertScriptContains(t, contract.script, []string{
"el.addPersonaToggle.addEventListener('click', togglePersonaForm)",
+ "el.editPersona.addEventListener('click', startPersonaEdit)",
+ "el.deletePersona.addEventListener('click', deleteSelectedPersona)",
"el.addPersonaForm.addEventListener('submit', createPersona)",
"el.cancelPersona.addEventListener('click', hidePersonaForm)",
- "requestJSON('/api/personas', {",
+ "editing ? `/api/personas/${encodeURIComponent(personaId)}` : '/api/personas'",
"method: 'POST'",
+ "`/api/personas/${encodeURIComponent(personaId)}`",
+ "method: editing ? 'PATCH' : 'POST'",
+ "`/api/personas/${encodeURIComponent(persona.id)}`",
+ "method: 'DELETE'",
"populatePersonaSelect()",
"populateHistoryPersonaSelect()",
- "additiveEndpointStatus(error, '書き手追加APIはまだ接続されていません。バックエンド実装後に保存できます。')",
+ "書き手追加APIはまだ接続されていません。バックエンド実装後に保存できます。",
+ "書き手更新APIはまだ接続されていません。バックエンド実装後に保存できます。",
+ "書き手削除APIはまだ接続されていません。バックエンド実装後に削除できます。",
+ })
+ assertFunctionContains(t, contract.script, "startPersonaEdit", []string{
+ "state.personaFormMode = 'edit'",
+ "state.editingPersonaId = persona.id",
+ "el.personaFormTitle.textContent = '書き手を編集'",
+ "fillPersonaForm(persona)",
})
assertFunctionContains(t, contract.script, "personaPayloadFromForm", []string{
"id: slugifyPersonaId(el.personaIdInput.value || el.personaNameInput.value)",
@@ -116,6 +133,12 @@ func TestPersonaAuthoringContract(t *testing.T) {
"state.personas = [",
"...state.personas.filter((item) => item.id !== persona.id)",
})
+ assertFunctionContains(t, contract.script, "deleteSelectedPersona", []string{
+ "window.confirm",
+ "state.personas = state.personas.filter((item) => item.id !== persona.id)",
+ "config.mode.persona = nextPersona?.id || ''",
+ "await loadWorkflowHistory()",
+ })
}
func TestModelSelectorConfigContract(t *testing.T) {
@@ -392,11 +415,14 @@ func TestArtifactCardEditContract(t *testing.T) {
assertScriptContains(t, contract.script, []string{
"styleEditMode: false",
"briefEditMode: false",
+ "briefVersionsVisible: false",
"'edit-brief-btn'",
+ "'show-brief-versions-btn'",
"'edit-style-guide-btn'",
"PATCH",
"`/api/author-style/${encodeURIComponent(styleId)}`",
"`/api/briefs/${encodeURIComponent(sessionId)}`",
+ "`/api/briefs/${encodeURIComponent(sessionId)}/versions`",
"additiveEndpointStatus(error, '文体ガイド編集APIはまだ接続されていません。内容は保存されませんでした。')",
"additiveEndpointStatus(error, '記事ブリーフ編集APIはまだ接続されていません。内容は保存されませんでした。')",
})
@@ -410,8 +436,24 @@ func TestArtifactCardEditContract(t *testing.T) {
assertFunctionContains(t, contract.script, "renderBriefCard", []string{
"if (state.briefEditMode)",
"renderBriefEditForm(brief)",
- "createCardEditButton('記事ブリーフを編集'",
+ "createBriefCardActions(brief)",
"appendArtifactStatus(el.briefCard, state.briefEditStatus, state.briefEditStatusType)",
+ "renderBriefVersionHistory(brief)",
+ })
+ assertFunctionContains(t, contract.script, "createBriefCardActions", []string{
+ "createCardEditButton('記事ブリーフを編集'",
+ "createCardActionButton('履歴', '記事ブリーフのバージョン履歴を表示'",
+ })
+ assertFunctionContains(t, contract.script, "toggleBriefVersions", []string{
+ "`/api/briefs/${encodeURIComponent(sessionId)}/versions`",
+ "normalizeBriefVersions(data)",
+ "briefVersionErrorMessage(error)",
+ })
+ assertFunctionContains(t, contract.script, "renderBriefVersionHistory", []string{
+ "section.id = 'brief-version-history'",
+ "state.briefVersionsLoading",
+ "state.briefVersionsError",
+ "briefVersionLine(version)",
})
assertFunctionContains(t, contract.script, "renderBriefEditForm", []string{
"form.id = 'brief-edit-form'",
diff --git a/static/index.html b/static/index.html
index b42902e..36f9aac 100644
--- a/static/index.html
+++ b/static/index.html
@@ -34,6 +34,8 @@ 設定
書き手
+ 編集
+ 削除
+ Add persona
@@ -60,7 +62,7 @@
設定