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:
1 parent
8b403c54df
commit
af1cb4f6dc
3 files changed
+131
-33
No files matched your search
+34
-32
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user