feat(ai): localize hitl Discord messages via HITL_LANG (en, ja)

Discord に投稿する提案・エスカレーション・期限切れ・ボタンの文言を
言語セット(en / ja)に分離し、HITL_LANG で起動時に選択できるようにした。
識別子(action id、ラベル、run id、JSON)は言語に関係なくそのまま出す。
This commit is contained in:
seon committed 2026-10-08 14:33:48 +09:00
1 parent 8b403c54df
commit af1cb4f6dc
3 files changed
+131 -33

No files matched your search

+1 -1
View File
@@ -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
+34 -32
View File
@@ -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 <t:%d:T>", 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 {
+96
View File
@@ -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:%d:T>", 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:%d:T>)", 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"]
}