From af1cb4f6dc910304aa217be04166ec9ad39fc93c Mon Sep 17 00:00:00 2001 From: seonNoh Date: Thu, 8 Oct 2026 14:33:48 +0900 Subject: [PATCH] feat(ai): localize hitl Discord messages via HITL_LANG (en, ja) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord に投稿する提案・エスカレーション・期限切れ・ボタンの文言を 言語セット(en / ja)に分離し、HITL_LANG で起動時に選択できるようにした。 識別子(action id、ラベル、run id、JSON)は言語に関係なくそのまま出す。 --- ai/cmd/hitl/main.go | 2 +- ai/internal/hitl/bot.go | 66 +++++++++++++-------------- ai/internal/hitl/i18n.go | 96 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 ai/internal/hitl/i18n.go diff --git a/ai/cmd/hitl/main.go b/ai/cmd/hitl/main.go index b058112..efe59ca 100644 --- a/ai/cmd/hitl/main.go +++ b/ai/cmd/hitl/main.go @@ -33,7 +33,7 @@ func main() { if err != nil { log.Fatalf("rag: %v", err) } - cfg := hitl.Config{GuildID: env("DISCORD_GUILD_ID", ""), ApprovalsChannel: env("DISCORD_APPROVALS_CHANNEL_ID", ""), EscalationChannel: env("DISCORD_ESCALATION_CHANNEL_ID", ""), ApproverRoleID: env("DISCORD_APPROVER_ROLE_ID", "")} + cfg := hitl.Config{GuildID: env("DISCORD_GUILD_ID", ""), ApprovalsChannel: env("DISCORD_APPROVALS_CHANNEL_ID", ""), EscalationChannel: env("DISCORD_ESCALATION_CHANNEL_ID", ""), ApproverRoleID: env("DISCORD_APPROVER_ROLE_ID", ""), Lang: env("HITL_LANG", "en")} tokB, err := os.ReadFile(env("DISCORD_TOKEN_FILE", "/run/secrets/discord-bot-token")) token := strings.TrimSpace(string(tokB)) connected := false diff --git a/ai/internal/hitl/bot.go b/ai/internal/hitl/bot.go index 8236873..c1727bc 100644 --- a/ai/internal/hitl/bot.go +++ b/ai/internal/hitl/bot.go @@ -23,6 +23,7 @@ type Config struct { ApprovalsChannel string EscalationChannel string ApproverRoleID string // optional: only members with this role may decide + Lang string // en | ja (HITL_LANG) } // Discord abstracts the discordgo calls so the decision logic is testable without a gateway. @@ -34,6 +35,7 @@ type Discord interface { type Bot struct { cfg Config + t Strings dc Discord harness *harness.Client store *rag.Store @@ -42,7 +44,7 @@ type Bot struct { } func New(cfg Config, dc Discord, hc *harness.Client, store *rag.Store) *Bot { - return &Bot{cfg: cfg, dc: dc, harness: hc, store: store, timers: map[string]*time.Timer{}} + return &Bot{cfg: cfg, t: Lang(cfg.Lang), dc: dc, harness: hc, store: store, timers: map[string]*time.Timer{}} } // PostRequest is what the agent sends to POST /post. @@ -64,7 +66,8 @@ func deref(s *string) string { return *s } -func embedFor(d map[string]any, actionID string, target map[string]any, runID, scenarioID *string, status string, color int) *discordgo.MessageEmbed { +func (b *Bot) embedFor(d map[string]any, actionID string, target map[string]any, runID, scenarioID *string, status string, color int) *discordgo.MessageEmbed { + t := b.t ev, _ := d["evidence"].([]any) var evLines []string for i, e := range ev { @@ -75,17 +78,17 @@ func embedFor(d map[string]any, actionID string, target map[string]any, runID, s } tgt, _ := json.Marshal(target) return &discordgo.MessageEmbed{ - Title: fmt.Sprintf("Layer-2 proposal: %s", actionID), + Title: t.ProposalTitle(actionID), Color: color, Fields: []*discordgo.MessageEmbedField{ - {Name: "Root cause", Value: fmt.Sprintf("%v (confidence %.2f)", d["root_cause_label"], toF(d["confidence"])), Inline: false}, - {Name: "Target", Value: "`" + string(tgt) + "`", Inline: false}, - {Name: "Evidence", Value: nonEmpty(strings.Join(evLines, "\n")), Inline: false}, - {Name: "Rationale", Value: nonEmpty(fmt.Sprint(d["rationale"])), Inline: false}, - {Name: "Run / scenario", Value: deref(runID) + " / " + deref(scenarioID), Inline: true}, - {Name: "Status", Value: status, Inline: true}, + {Name: t.FieldRootCause, Value: t.RootCauseValue(fmt.Sprint(d["root_cause_label"]), toF(d["confidence"])), Inline: false}, + {Name: t.FieldTarget, Value: "`" + string(tgt) + "`", Inline: false}, + {Name: t.FieldEvidence, Value: nonEmpty(strings.Join(evLines, "\n")), Inline: false}, + {Name: t.FieldRationale, Value: nonEmpty(fmt.Sprint(d["rationale"])), Inline: false}, + {Name: t.FieldRun, Value: deref(runID) + " / " + deref(scenarioID), Inline: true}, + {Name: t.FieldStatus, Value: status, Inline: true}, }, - Footer: &discordgo.MessageEmbedFooter{Text: fmt.Sprintf("model %v, prompt %v, session %v", d["model"], d["prompt_version"], d["session_id"])}, + Footer: &discordgo.MessageEmbedFooter{Text: t.Footer(fmt.Sprint(d["model"]), fmt.Sprint(d["prompt_version"]), fmt.Sprint(d["session_id"]))}, } } @@ -112,10 +115,10 @@ const ( colorEscalate = 0xfee75c ) -func buttons(token string, disabled bool) []discordgo.MessageComponent { +func (b *Bot) buttons(token string, disabled bool) []discordgo.MessageComponent { return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ - discordgo.Button{Label: "Approve", Style: discordgo.SuccessButton, CustomID: "approve:" + token, Disabled: disabled}, - discordgo.Button{Label: "Reject", Style: discordgo.DangerButton, CustomID: "reject:" + token, Disabled: disabled}, + discordgo.Button{Label: b.t.ButtonApprove, Style: discordgo.SuccessButton, CustomID: "approve:" + token, Disabled: disabled}, + discordgo.Button{Label: b.t.ButtonReject, Style: discordgo.DangerButton, CustomID: "reject:" + token, Disabled: disabled}, }}} } @@ -126,8 +129,8 @@ func (b *Bot) Post(ctx context.Context, req PostRequest) error { ttl = 15 * time.Minute } expires := time.Now().Add(ttl) - em := embedFor(req.Diagnosis, req.ActionID, req.Target, req.RunID, req.ScenarioID, fmt.Sprintf("waiting for approval until ", expires.Unix()), colorPending) - msg, err := b.dc.Send(b.cfg.ApprovalsChannel, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{em}, Components: buttons(req.Token, false)}) + em := b.embedFor(req.Diagnosis, req.ActionID, req.Target, req.RunID, req.ScenarioID, b.t.StatusWaiting(expires.Unix()), colorPending) + msg, err := b.dc.Send(b.cfg.ApprovalsChannel, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{em}, Components: b.buttons(req.Token, false)}) if err != nil { return err } @@ -191,27 +194,27 @@ func (b *Bot) Decide(ctx context.Context, token, decision, by string) (string, e if err := b.harness.Reject(ctx, token, by, "rejected in Discord"); err != nil { log.Printf("harness reject: %v", err) } - _ = b.edit(m, "rejected by "+by, colorRejected, nil) + _ = b.edit(m, b.t.StatusRejected(by), colorRejected, nil) return "rejected", nil case "approve": if time.Now().After(m.ExpiresAt) { - _ = b.edit(m, "expired before approval", colorExpired, nil) + _ = b.edit(m, b.t.StatusExpiredBefore, colorExpired, nil) return "expired", nil } if err := b.harness.Approve(ctx, token, by); err != nil { - _ = b.edit(m, "approval failed: "+err.Error(), colorRejected, nil) + _ = b.edit(m, b.t.StatusApproveFailed(err.Error()), colorRejected, nil) _ = b.store.SetHitlResult(ctx, token, map[string]any{"error": err.Error()}) return "approve failed", err } - _ = b.edit(m, "approved by "+by+", executing...", colorApproved, nil) + _ = b.edit(m, b.t.StatusExecuting(by), colorApproved, nil) res, err := b.harness.Execute(ctx, token, m.ActionID, m.Target, m.RunID, m.ScenarioID, "approved in Discord by "+by) if err != nil { _ = b.store.SetHitlResult(ctx, token, map[string]any{"error": err.Error(), "result": res}) - _ = b.edit(m, "approved by "+by+"; execution failed: "+truncate(err.Error(), 300), colorRejected, res) + _ = b.edit(m, b.t.StatusExecFailed(by, truncate(err.Error(), 300)), colorRejected, res) return "execute failed", err } _ = b.store.SetHitlResult(ctx, token, res) - _ = b.edit(m, "approved by "+by+"; executed", colorApproved, res) + _ = b.edit(m, b.t.StatusExecuted(by), colorApproved, res) return "executed", nil } return "", fmt.Errorf("unknown decision %s", decision) @@ -219,12 +222,12 @@ func (b *Bot) Decide(ctx context.Context, token, decision, by string) (string, e // edit re-renders the original message with the stored diagnosis, the new status and disabled buttons. func (b *Bot) edit(m *rag.HitlMessage, status string, color int, result map[string]any) error { - em := embedFor(m.Diagnosis, m.ActionID, m.Target, m.RunID, m.ScenarioID, status, color) + em := b.embedFor(m.Diagnosis, m.ActionID, m.Target, m.RunID, m.ScenarioID, status, color) if result != nil { rb, _ := json.Marshal(result) - em.Fields = append(em.Fields, &discordgo.MessageEmbedField{Name: "Result", Value: "```json\n" + truncate(string(rb), 900) + "\n```"}) + em.Fields = append(em.Fields, &discordgo.MessageEmbedField{Name: b.t.FieldResult, Value: "```json\n" + truncate(string(rb), 900) + "\n```"}) } - return b.dc.Edit(m.ChannelID, m.MessageID, em, buttons(m.Token, true)) + return b.dc.Edit(m.ChannelID, m.MessageID, em, b.buttons(m.Token, true)) } func (b *Bot) expire(token string) { @@ -237,9 +240,8 @@ func (b *Bot) expire(token string) { if err != nil || !first { return } - _ = b.edit(m, "no decision within the window; moved to layer 3", colorExpired, nil) - text := fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", - m.ActionID, mustJSON(m.Target), deref(m.RunID), deref(m.ScenarioID)) + _ = b.edit(m, b.t.StatusNoDecision, colorExpired, nil) + text := b.t.ExpiryMessage(m.ActionID, mustJSON(m.Target), deref(m.RunID), deref(m.ScenarioID)) if _, err := b.dc.Send(b.cfg.EscalationChannel, &discordgo.MessageSend{Content: text}); err != nil { log.Printf("escalation post: %v", err) } @@ -255,17 +257,17 @@ type EscalateRequest struct { } func (b *Bot) Escalate(ctx context.Context, req EscalateRequest) error { - em := embedFor(req.Diagnosis, "escalate", map[string]any{"target": req.Diagnosis["target"]}, req.RunID, req.ScenarioID, "layer 3: human decision required", colorEscalate) - em.Title = fmt.Sprintf("Escalation: %v", req.Diagnosis["root_cause_label"]) + em := b.embedFor(req.Diagnosis, "escalate", map[string]any{"target": req.Diagnosis["target"]}, req.RunID, req.ScenarioID, b.t.EscalationStatus, colorEscalate) + em.Title = b.t.EscalationTitle(fmt.Sprint(req.Diagnosis["root_cause_label"])) if an, ok := req.Alert["alertname"]; ok { - em.Fields = append(em.Fields, &discordgo.MessageEmbedField{Name: "Alert", Value: fmt.Sprint(an), Inline: true}) + em.Fields = append(em.Fields, &discordgo.MessageEmbedField{Name: b.t.FieldAlert, Value: fmt.Sprint(an), Inline: true}) } msg, err := b.dc.Send(b.cfg.EscalationChannel, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{em}}) if err != nil { return err } name := fmt.Sprintf("%s %s", deref(req.ScenarioID), time.Now().UTC().Format("01-02 15:04")) - text := "Runbook:\n" + nonEmpty(truncate(req.Runbook, 1800)) + text := b.t.ThreadRunbook + nonEmpty(truncate(req.Runbook, 1800)) if err := b.dc.Thread(msg.ChannelID, msg.ID, name, text); err != nil { log.Printf("thread: %v", err) } @@ -338,7 +340,7 @@ func (b *Bot) HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCr } if !b.Allowed(roles) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{Content: "You are not in the approver role.", Flags: discordgo.MessageFlagsEphemeral}}) + Data: &discordgo.InteractionResponseData{Content: b.t.NotAllowed, Flags: discordgo.MessageFlagsEphemeral}}) return } if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate}); err != nil { diff --git a/ai/internal/hitl/i18n.go b/ai/internal/hitl/i18n.go new file mode 100644 index 0000000..f8c4a30 --- /dev/null +++ b/ai/internal/hitl/i18n.go @@ -0,0 +1,96 @@ +package hitl + +import "fmt" + +// Strings holds every user-facing text of the bot. The language is chosen once at start (HITL_LANG=en|ja). +// Identifiers (action ids, labels, run ids, JSON) stay as they are; only the surrounding text changes. +type Strings struct { + ProposalTitle func(actionID string) string + FieldRootCause string + FieldTarget string + FieldEvidence string + FieldRationale string + FieldRun string + FieldStatus string + FieldResult string + FieldAlert string + RootCauseValue func(label string, conf float64) string + ButtonApprove string + ButtonReject string + StatusWaiting func(unixTS int64) string + StatusRejected func(by string) string + StatusExecuting func(by string) string + StatusExecuted func(by string) string + StatusApproveFailed func(err string) string + StatusExecFailed func(by, err string) string + StatusExpiredBefore string + StatusNoDecision string + ExpiryMessage func(actionID, target, runID, scenarioID string) string + EscalationTitle func(label string) string + EscalationStatus string + ThreadRunbook string + NotAllowed string + Footer func(model, prompt, session string) string + Empty string +} + +var langs = map[string]Strings{ + "en": { + ProposalTitle: func(a string) string { return "Layer-2 proposal: " + a }, + FieldRootCause: "Root cause", FieldTarget: "Target", FieldEvidence: "Evidence", FieldRationale: "Rationale", + FieldRun: "Run / scenario", FieldStatus: "Status", FieldResult: "Result", FieldAlert: "Alert", + RootCauseValue: func(l string, c float64) string { return fmt.Sprintf("%s (confidence %.2f)", l, c) }, + ButtonApprove: "Approve", ButtonReject: "Reject", + StatusWaiting: func(t int64) string { return fmt.Sprintf("waiting for approval until ", t) }, + StatusRejected: func(by string) string { return "rejected by " + by }, + StatusExecuting: func(by string) string { return "approved by " + by + ", executing..." }, + StatusExecuted: func(by string) string { return "approved by " + by + "; executed" }, + StatusApproveFailed: func(e string) string { return "approval failed: " + e }, + StatusExecFailed: func(by, e string) string { return "approved by " + by + "; execution failed: " + e }, + StatusExpiredBefore: "expired before approval", + StatusNoDecision: "no decision within the window; moved to layer 3", + ExpiryMessage: func(a, t, r, s string) string { + return fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", a, t, r, s) + }, + EscalationTitle: func(l string) string { return "Escalation: " + l }, + EscalationStatus: "layer 3: human decision required", + ThreadRunbook: "Runbook:\n", + NotAllowed: "You are not in the approver role.", + Footer: func(m, p, s string) string { return fmt.Sprintf("model %s, prompt %s, session %s", m, p, s) }, + Empty: "-", + }, + "ja": { + ProposalTitle: func(a string) string { return "第2層の提案: " + a }, + FieldRootCause: "推定原因", FieldTarget: "対象", FieldEvidence: "根拠", FieldRationale: "判断理由", + FieldRun: "Run / シナリオ", FieldStatus: "状態", FieldResult: "実行結果", FieldAlert: "アラート", + RootCauseValue: func(l string, c float64) string { return fmt.Sprintf("%s(確信度 %.2f)", l, c) }, + ButtonApprove: "承認", ButtonReject: "却下", + StatusWaiting: func(t int64) string { return fmt.Sprintf("承認待ち(期限 )", t) }, + StatusRejected: func(by string) string { return by + " が却下" }, + StatusExecuting: func(by string) string { return by + " が承認、実行中..." }, + StatusExecuted: func(by string) string { return by + " が承認、実行済み" }, + StatusApproveFailed: func(e string) string { return "承認の記録に失敗: " + e }, + StatusExecFailed: func(by, e string) string { return by + " が承認したが実行に失敗: " + e }, + StatusExpiredBefore: "承認前に期限切れ", + StatusNoDecision: "期限内に判断がなかったため第3層へ引き継ぎ", + ExpiryMessage: func(a, t, r, s string) string { + return fmt.Sprintf("提案 `%s`(対象 `%s`、run %s、シナリオ %s)は承認期限内に判断されませんでした。第3層の対応です。ランブックに従って手動で対処するか、オペレーター用トークンでハーネスの `/actions/execute` を呼び出してください。", a, t, r, s) + }, + EscalationTitle: func(l string) string { return "エスカレーション: " + l }, + EscalationStatus: "第3層: 人の判断が必要", + ThreadRunbook: "ランブック:\n", + NotAllowed: "承認ロールが付与されていません。", + Footer: func(m, p, s string) string { + return fmt.Sprintf("モデル %s、プロンプト %s、セッション %s", m, p, s) + }, + Empty: "-", + }, +} + +// Lang returns the string set for a language code; unknown codes fall back to English. +func Lang(code string) Strings { + if s, ok := langs[code]; ok { + return s + } + return langs["en"] +}