Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/adrs/0002-multi-persona-multi-format-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/implementation-plans/next-implementation-cut.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions docs/validation/issue-68-media-aware-style-source-2026-05-03.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion internal/domain/persona/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{"僕", "私"},
Expand Down
137 changes: 122 additions & 15 deletions internal/handlers/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -215,17 +228,33 @@ 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 {
respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest)
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,
})
Expand Down Expand Up @@ -274,25 +303,103 @@ 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),
}, "\n\n")
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{
Expand Down Expand Up @@ -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...)
Expand Down
41 changes: 37 additions & 4 deletions internal/handlers/workflow_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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"}`))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/workflow_regenerate_section_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/workflow_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ <h3>取材質問</h3>
<span class="step-label">1</span>
<h2>文体分析 / プリセット</h2>
</div>
<p>note記事の分析、または書き手プリセットから再利用できる文体ガイドを作ります。</p>
<p>選択中の書き手と出力先に合わせて、note / Zenn / Qiita / 自社ブログから文体ガイドを作ります。</p>
</div>

<div class="field-row">
<label for="style-username">Noteユーザー名</label>
<label for="style-username">文体ソース</label>
<input id="style-username" type="text" value="cor_instrument" autocomplete="off">
</div>
<div class="field-row compact">
Expand Down
Loading