Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.
Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
announce_channel_id VARCHAR(64) NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
topic creator opted out of broadcasts (announcer skips with log).
handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.
fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.
orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.
verdict.go: same per-topic target extraction at completion.
config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").
FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
187 lines
6.5 KiB
Go
187 lines
6.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
|
|
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/fabric"
|
|
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
|
|
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store"
|
|
)
|
|
|
|
type VerdictHandler struct {
|
|
topics *store.TopicStore
|
|
camps *store.CampStore
|
|
verdicts *store.VerdictStore
|
|
announcer *fabric.Announcer // optional; nil-safe via Enabled() check
|
|
}
|
|
|
|
func NewVerdictHandler(t *store.TopicStore, c *store.CampStore, v *store.VerdictStore, ann *fabric.Announcer) *VerdictHandler {
|
|
return &VerdictHandler{topics: t, camps: c, verdicts: v, announcer: ann}
|
|
}
|
|
|
|
type submitVerdictBody struct {
|
|
Verdict json.RawMessage `json:"verdict"` // shape matches topic.verdict_schema_id
|
|
Rationale string `json:"rationale"`
|
|
TokensInput int `json:"tokens_input"`
|
|
TokensOutput int `json:"tokens_output"`
|
|
}
|
|
|
|
// POST /api/topics/{id}/verdict
|
|
//
|
|
// Judge-only. Caller must be allocated to the judge camp. Topic must be
|
|
// in `debating` status AND past `debate_end_at` (the ticker doesn't
|
|
// flip to `judging` in v1, see ticker.go note — the gate enforces the
|
|
// time crossing instead).
|
|
//
|
|
// Schema validation (Phase 2D): shallow — confirm verdict is valid JSON
|
|
// and not empty. Real schema-shape validation lands when we wire the
|
|
// verdict_schemas.shape_json against a JSON-schema validator.
|
|
func (h *VerdictHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|
caller := auth.FromContext(r.Context())
|
|
if caller.Kind != auth.CallerAgent {
|
|
http.Error(w, "verdict submission is agent-only", http.StatusForbidden)
|
|
return
|
|
}
|
|
topicID := chi.URLParam(r, "id")
|
|
topic, err := h.topics.GetByID(r.Context(), topicID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "topic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if topic.Status != models.TopicStatusDebating {
|
|
http.Error(w, "topic not in debate state (status="+string(topic.Status)+")", http.StatusConflict)
|
|
return
|
|
}
|
|
if time.Now().Before(topic.DebateEndAt) {
|
|
http.Error(w, "debate window still open; verdict premature", http.StatusConflict)
|
|
return
|
|
}
|
|
camp, err := h.camps.AgentCampInTopic(r.Context(), topicID, caller.ID)
|
|
if err != nil || camp != models.CampJudge {
|
|
http.Error(w, "only the judge can submit a verdict", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var body submitVerdictBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(body.Verdict) == 0 || string(body.Verdict) == "null" {
|
|
http.Error(w, "verdict required (non-empty JSON object matching schema)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Sanity: ensure it parses as a JSON object/value.
|
|
var probe any
|
|
if err := json.Unmarshal(body.Verdict, &probe); err != nil {
|
|
http.Error(w, "verdict must be valid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Rationale == "" {
|
|
http.Error(w, "rationale required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
verdict, err := h.verdicts.Submit(r.Context(), store.SubmitVerdictInput{
|
|
TopicID: topicID,
|
|
JudgeAgentID: caller.ID,
|
|
VerdictJSON: body.Verdict,
|
|
Rationale: body.Rationale,
|
|
TokensInput: body.TokensInput,
|
|
TokensOutput: body.TokensOutput,
|
|
})
|
|
if err != nil {
|
|
// Most likely cause: unique-key conflict (already submitted).
|
|
http.Error(w, "submit failed: "+err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Transition topic to completed. Best-effort; if it fails, the
|
|
// verdict row exists and the ticker will retry on next scan
|
|
// (well — once we add that transition; v1 leaves it to a manual
|
|
// flip via SQL or a follow-up endpoint).
|
|
if _, err := h.topics.SetStatus(r.Context(), topicID, models.TopicStatusCompleted); err != nil {
|
|
// non-fatal: log via response header (caller can spot-check)
|
|
w.Header().Set("x-warn", "verdict saved but status update failed: "+err.Error())
|
|
}
|
|
|
|
// Lifecycle broadcast — completed event. Best-effort; runs async
|
|
// outside the request context so a slow Fabric doesn't slow the
|
|
// judge's response. Target is per-topic (nil announcer or nil
|
|
// target on topic → silently skipped by announcer).
|
|
if h.announcer != nil {
|
|
var tgt fabric.Target
|
|
if topic.AnnounceGuildBaseURL != nil && topic.AnnounceChannelID != nil {
|
|
tgt = fabric.Target{
|
|
GuildBaseURL: *topic.AnnounceGuildBaseURL,
|
|
ChannelID: *topic.AnnounceChannelID,
|
|
}
|
|
}
|
|
go func(t *fabric.Announcer, tID, title, judge string, target fabric.Target) {
|
|
summary := fmt.Sprintf("verdict published by judge=%s. Use dialectic_view_verdict to see the structured result.", judge)
|
|
_ = t.PostLifecycleEvent(context.Background(), target, tID, title, "completed", summary)
|
|
}(h.announcer, verdict.TopicID, topic.Title, caller.ID, tgt)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"id": verdict.ID,
|
|
"topic_id": verdict.TopicID,
|
|
"judge_agent_id": verdict.JudgeAgentID,
|
|
"verdict": json.RawMessage(verdict.VerdictJSON),
|
|
"rationale": verdict.Rationale,
|
|
"tokens_input": verdict.TokensInput,
|
|
"tokens_output": verdict.TokensOutput,
|
|
"produced_at": verdict.ProducedAt,
|
|
})
|
|
}
|
|
|
|
// GET /api/topics/{id}/verdict — fetch the published verdict (404 if
|
|
// not yet produced). Visibility-gated like other read endpoints.
|
|
func (h *VerdictHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
topicID := chi.URLParam(r, "id")
|
|
topic, err := h.topics.GetByID(r.Context(), topicID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "topic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
caller := auth.FromContext(r.Context())
|
|
if caller.Kind == "" && topic.Visibility != models.VisibilityPublic {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
verdict, err := h.verdicts.GetByTopic(r.Context(), topicID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "verdict not yet produced", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": verdict.ID,
|
|
"topic_id": verdict.TopicID,
|
|
"judge_agent_id": verdict.JudgeAgentID,
|
|
"verdict": json.RawMessage(verdict.VerdictJSON),
|
|
"rationale": verdict.Rationale,
|
|
"produced_at": verdict.ProducedAt,
|
|
})
|
|
}
|