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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
347 changes: 347 additions & 0 deletions cmd/scenario/live_media_matrix/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading