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
251 changes: 251 additions & 0 deletions cmd/scenario/format_persona_seed/main.go
Original file line number Diff line number Diff line change
@@ -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: "<section><h2>形式別に伝わる下書きへ</h2><p>同じ材料でも、出力先に合わせて構成と記法を切り替えることで、読み手が次の行動を取りやすくなります。</p><a href=\"/contact\">相談する</a></section>",
}
}

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)
}
5 changes: 3 additions & 2 deletions docs/adrs/0002-multi-persona-multi-format-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/implementation-plans/multi-persona-multi-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/`).
Expand Down Expand Up @@ -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/`:
Expand Down Expand Up @@ -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).
Loading