package handlers import ( "encoding/json" "errors" "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/models" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) type VerdictHandler struct { topics *store.TopicStore camps *store.CampStore verdicts *store.VerdictStore } func NewVerdictHandler(t *store.TopicStore, c *store.CampStore, v *store.VerdictStore) *VerdictHandler { return &VerdictHandler{topics: t, camps: c, verdicts: v} } 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()) } 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, }) }