1131 lines
31 KiB
Go
1131 lines
31 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/relata/internal/analysis"
|
|
"github.com/andydixon/relata/internal/audit"
|
|
"github.com/andydixon/relata/internal/auth"
|
|
"github.com/andydixon/relata/internal/config"
|
|
"github.com/andydixon/relata/internal/domain"
|
|
"github.com/andydixon/relata/internal/ids"
|
|
"github.com/andydixon/relata/internal/ingest"
|
|
"github.com/andydixon/relata/internal/relationships"
|
|
"github.com/andydixon/relata/internal/search"
|
|
)
|
|
|
|
// Deps bundles what the handlers need.
|
|
type Deps struct {
|
|
Cfg *config.Config
|
|
Items domain.ItemRepository
|
|
Chunks domain.ChunkRepository
|
|
Entities domain.EntityRepository
|
|
Aliases domain.AliasRepository
|
|
Claims domain.ClaimRepository
|
|
Rels domain.RelationshipRepository
|
|
Jobs domain.JobRepository
|
|
Runs domain.RunRepository
|
|
Models domain.ModelVersionRepository
|
|
Vectors domain.VectorStore
|
|
|
|
Ingest *ingest.Service
|
|
Pipeline *ingest.Pipeline
|
|
Search *search.Service
|
|
Answer *search.Answerer
|
|
Reviewer *relationships.Reviewer
|
|
Analysis *analysis.Service
|
|
Audit *audit.Recorder
|
|
Tokens *auth.TokenService
|
|
|
|
Embedder domain.Embedder
|
|
}
|
|
|
|
// Handlers implements the REST API.
|
|
type Handlers struct {
|
|
d Deps
|
|
}
|
|
|
|
// NewHandlers builds the API handlers.
|
|
func NewHandlers(d Deps) *Handlers { return &Handlers{d: d} }
|
|
|
|
// principal returns the authenticated caller, or an error.
|
|
func principal(r *http.Request) (*domain.Principal, error) {
|
|
p := PrincipalFrom(r.Context())
|
|
if p == nil || p.WorkspaceID == "" {
|
|
return nil, &APIError{Status: http.StatusUnauthorized, Code: CodeUnauthorised,
|
|
Message: "Authentication is required."}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// pathID extracts and validates a UUID path parameter.
|
|
//
|
|
// Validating the shape before any query means a crafted identifier never
|
|
// reaches the database layer at all.
|
|
func pathID(r *http.Request, name string) (string, error) {
|
|
v := r.PathValue(name)
|
|
if !ids.Valid(v) {
|
|
return "", &APIError{Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
|
Message: fmt.Sprintf("The %s must be a UUID.", name)}
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Items
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// createItemRequest is the JSON body for POST /api/v1/items.
|
|
type createItemRequest struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
SourceType string `json:"source_type"`
|
|
MIMEType string `json:"mime_type"`
|
|
Filename string `json:"filename"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
CreatedAt string `json:"created_at"`
|
|
AllowDuplicate bool `json:"allow_duplicate"`
|
|
}
|
|
|
|
// CreateItem ingests pasted text or a JSON document.
|
|
func (h *Handlers) CreateItem(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var req createItemRequest
|
|
if err := DecodeJSON(w, r, h.d.Cfg.Uploads.MaxUploadBytes, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Content) == "" {
|
|
WriteError(w, r, BadRequest("The item has no content.",
|
|
FieldError{Field: "content", Message: "must not be empty"}))
|
|
return
|
|
}
|
|
|
|
ing := ingest.Request{
|
|
Title: req.Title,
|
|
Content: []byte(req.Content),
|
|
Filename: req.Filename,
|
|
DeclaredMIME: req.MIMEType,
|
|
Metadata: req.Metadata,
|
|
AllowDuplicate: req.AllowDuplicate,
|
|
}
|
|
if req.SourceType != "" {
|
|
st := domain.SourceType(req.SourceType)
|
|
if !st.Valid() {
|
|
WriteError(w, r, BadRequest("Unrecognised source type.",
|
|
FieldError{Field: "source_type", Message: "must be one of " + sourceTypeList()}))
|
|
return
|
|
}
|
|
ing.SourceType = st
|
|
}
|
|
if req.CreatedAt != "" {
|
|
t, perr := parseTime(req.CreatedAt)
|
|
if perr != nil {
|
|
WriteError(w, r, BadRequest("Unrecognised creation date.",
|
|
FieldError{Field: "created_at", Message: "must be RFC 3339, for example 2025-03-14T09:00:00Z"}))
|
|
return
|
|
}
|
|
ing.CreatedAt = &t
|
|
}
|
|
|
|
res, err := h.d.Ingest.Ingest(r.Context(), p, ing)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"item": res.Item,
|
|
"job_id": res.JobID,
|
|
"status": "queued for processing",
|
|
})
|
|
}
|
|
|
|
// UploadItem ingests a multipart file upload.
|
|
func (h *Handlers) UploadItem(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
limit := h.d.Cfg.Uploads.MaxUploadBytes
|
|
// Allow a little headroom for multipart framing above the file limit.
|
|
r.Body = http.MaxBytesReader(w, r.Body, limit+(1<<20))
|
|
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
WriteError(w, r, &APIError{
|
|
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
|
|
Message: fmt.Sprintf("The upload exceeds the %d byte limit.", limit),
|
|
})
|
|
return
|
|
}
|
|
WriteError(w, r, BadRequest("The upload could not be read as a multipart form."))
|
|
return
|
|
}
|
|
defer func() {
|
|
if r.MultipartForm != nil {
|
|
_ = r.MultipartForm.RemoveAll()
|
|
}
|
|
}()
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
WriteError(w, r, BadRequest("No file was supplied.",
|
|
FieldError{Field: "file", Message: "a file is required"}))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Read at most limit+1 bytes so an oversized file is detected rather than
|
|
// silently truncated.
|
|
content, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
if err != nil {
|
|
WriteError(w, r, BadRequest("The uploaded file could not be read."))
|
|
return
|
|
}
|
|
if int64(len(content)) > limit {
|
|
WriteError(w, r, &APIError{
|
|
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
|
|
Message: fmt.Sprintf("The file exceeds the %d byte limit.", limit),
|
|
})
|
|
return
|
|
}
|
|
|
|
ing := ingest.Request{
|
|
Title: r.FormValue("title"),
|
|
Content: content,
|
|
Filename: header.Filename,
|
|
DeclaredMIME: header.Header.Get("Content-Type"),
|
|
Metadata: metadataFromForm(r),
|
|
AllowDuplicate: r.FormValue("allow_duplicate") == "true",
|
|
}
|
|
if st := r.FormValue("source_type"); st != "" {
|
|
if s := domain.SourceType(st); s.Valid() {
|
|
ing.SourceType = s
|
|
}
|
|
}
|
|
if ca := r.FormValue("created_at"); ca != "" {
|
|
if t, perr := parseTime(ca); perr == nil {
|
|
ing.CreatedAt = &t
|
|
}
|
|
}
|
|
|
|
res, err := h.d.Ingest.Ingest(r.Context(), p, ing)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"item": res.Item,
|
|
"job_id": res.JobID,
|
|
"status": "queued for processing",
|
|
})
|
|
}
|
|
|
|
// ListItems returns a page of items.
|
|
func (h *Handlers) ListItems(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
f, ferr := itemFilterFromQuery(r)
|
|
if ferr != nil {
|
|
WriteError(w, r, ferr)
|
|
return
|
|
}
|
|
page := pageFromQuery(r)
|
|
|
|
items, total, err := h.d.Items.List(r.Context(), p.WorkspaceID, f, page)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"pagination": map[string]any{
|
|
"total": total, "limit": page.Limit, "offset": page.Offset,
|
|
"has_more": int64(page.Offset+len(items)) < total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetItem returns one item with its derived records.
|
|
func (h *Handlers) GetItem(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
item, err := h.d.Items.Get(r.Context(), p.WorkspaceID, id)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
chunks, _ := h.d.Chunks.ListByItem(ctx, p.WorkspaceID, id)
|
|
ents, _ := h.d.Entities.ListByItem(ctx, p.WorkspaceID, id)
|
|
claims, _ := h.d.Claims.ListByItem(ctx, p.WorkspaceID, id)
|
|
outRels, inRels, _ := h.d.Rels.ListForNode(ctx, p.WorkspaceID, domain.NodeItem, id, domain.RelationshipFilter{})
|
|
runs, _ := h.d.Runs.ListByItem(ctx, p.WorkspaceID, id, 10)
|
|
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"item": item,
|
|
"chunks": chunks,
|
|
"entities": ents,
|
|
"claims": claims,
|
|
"outgoing_relationships": withExplanations(outRels),
|
|
"incoming_relationships": withExplanations(inRels),
|
|
"processing_runs": runs,
|
|
})
|
|
}
|
|
|
|
// DeleteItem removes an item, or reports what would be removed.
|
|
func (h *Handlers) DeleteItem(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// A dry run reports the blast radius and changes nothing.
|
|
if r.URL.Query().Get("dry_run") == "true" {
|
|
plan, err := h.d.Pipeline.PlanDeletion(r.Context(), p.WorkspaceID, id)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"dry_run": true,
|
|
"plan": plan,
|
|
"note": "Nothing has been deleted. Repeat this request without dry_run=true to proceed.",
|
|
})
|
|
return
|
|
}
|
|
|
|
jobID, err := h.d.Pipeline.RequestDeletion(r.Context(), p, id)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"deleted": id,
|
|
"job_id": jobID,
|
|
"status": "The item is hidden immediately; its chunks, vectors, claims, orphaned entities and relationships are being removed.",
|
|
})
|
|
}
|
|
|
|
// ReprocessItem queues an item to be processed again.
|
|
func (h *Handlers) ReprocessItem(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if r.ContentLength > 0 {
|
|
if err := DecodeJSON(w, r, 8<<10, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
jobID, err := h.d.Pipeline.RequestReprocess(r.Context(), p, id, req.Reason)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"item_id": id, "job_id": jobID, "status": "queued for reprocessing",
|
|
})
|
|
}
|
|
|
|
// GetItemRelationships returns an item's relationships.
|
|
func (h *Handlers) GetItemRelationships(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
f, ferr := relFilterFromQuery(r)
|
|
if ferr != nil {
|
|
WriteError(w, r, ferr)
|
|
return
|
|
}
|
|
|
|
out, in, err := h.d.Rels.ListForNode(r.Context(), p.WorkspaceID, domain.NodeItem, id, f)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Resolve the titles at the far end of each relationship.
|
|
var otherIDs []string
|
|
for _, rel := range append(append([]*domain.Relationship{}, out...), in...) {
|
|
otherIDs = append(otherIDs, rel.SourceID, rel.TargetID)
|
|
}
|
|
titles := map[string]string{}
|
|
if items, err := h.d.Items.GetMany(r.Context(), p.WorkspaceID, otherIDs); err == nil {
|
|
for _, it := range items {
|
|
titles[it.ID] = it.Title
|
|
}
|
|
}
|
|
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"item_id": id,
|
|
"outgoing": withTitles(withExplanations(out), titles),
|
|
"incoming": withTitles(withExplanations(in), titles),
|
|
"note": "Relationship strength is a heuristic score derived from the listed signals. " +
|
|
"It is not a calibrated probability.",
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Relationships
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateRelationship records a relationship a person asserted.
|
|
func (h *Handlers) CreateRelationship(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
SourceID string `json:"source_id"`
|
|
TargetID string `json:"target_id"`
|
|
Type string `json:"type"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
rel, err := h.d.Reviewer.CreateManual(r.Context(), p, req.SourceID, req.TargetID,
|
|
domain.RelationshipType(req.Type), req.Note)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusCreated, relResponse(rel))
|
|
}
|
|
|
|
// PatchRelationship changes a relationship's type.
|
|
func (h *Handlers) PatchRelationship(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
WriteError(w, r, BadRequest("No change was requested.",
|
|
FieldError{Field: "type", Message: "a relationship type is required"}))
|
|
return
|
|
}
|
|
|
|
rel, err := h.d.Reviewer.ChangeType(r.Context(), p, id, domain.RelationshipType(req.Type), req.Note)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, relResponse(rel))
|
|
}
|
|
|
|
// ConfirmRelationship records a human confirmation.
|
|
func (h *Handlers) ConfirmRelationship(w http.ResponseWriter, r *http.Request) {
|
|
h.review(w, r, "confirm")
|
|
}
|
|
|
|
// RejectRelationship records a human rejection.
|
|
func (h *Handlers) RejectRelationship(w http.ResponseWriter, r *http.Request) {
|
|
h.review(w, r, "reject")
|
|
}
|
|
|
|
func (h *Handlers) review(w http.ResponseWriter, r *http.Request, action string) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Note string `json:"note"`
|
|
}
|
|
if r.ContentLength > 0 {
|
|
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
var rel *domain.Relationship
|
|
if action == "confirm" {
|
|
rel, err = h.d.Reviewer.Confirm(r.Context(), p, id, req.Note)
|
|
} else {
|
|
rel, err = h.d.Reviewer.Reject(r.Context(), p, id, req.Note)
|
|
}
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, relResponse(rel))
|
|
}
|
|
|
|
// ListRelationships returns a page of relationships.
|
|
func (h *Handlers) ListRelationships(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
f, ferr := relFilterFromQuery(r)
|
|
if ferr != nil {
|
|
WriteError(w, r, ferr)
|
|
return
|
|
}
|
|
page := pageFromQuery(r)
|
|
|
|
rels, total, err := h.d.Rels.List(r.Context(), p.WorkspaceID, f, page)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"relationships": withExplanations(rels),
|
|
"pagination": map[string]any{
|
|
"total": total, "limit": page.Limit, "offset": page.Offset,
|
|
"has_more": int64(page.Offset+len(rels)) < total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Search runs a hybrid query.
|
|
func (h *Handlers) Search(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
q, qerr := searchQueryFromRequest(r)
|
|
if qerr != nil {
|
|
WriteError(w, r, qerr)
|
|
return
|
|
}
|
|
|
|
resp, err := h.d.Search.Search(r.Context(), p, q)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"search": resp,
|
|
"score_guide": map[string]string{
|
|
"query_relevance": "How well this item answers this particular query, relative to the best result. Not a probability.",
|
|
"semantic_similarity": "How close in meaning a matched section is to the query text. Similarity is not evidence of a factual relationship.",
|
|
"relationship_strength": "A heuristic strength derived from the listed signals, under a versioned scoring model. Not a calibrated probability.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Answer generates an answer from stored material only.
|
|
func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
if !h.d.Answer.Enabled() {
|
|
WriteError(w, r, &APIError{
|
|
Status: http.StatusServiceUnavailable, Code: CodeFeatureDisabled,
|
|
Message: "Answer generation requires a language model, which is not configured.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Question string `json:"question"`
|
|
SourceTypes []string `json:"source_types"`
|
|
EntityIDs []string `json:"entity_ids"`
|
|
ConfirmedOnly bool `json:"confirmed_only"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := DecodeJSON(w, r, 64<<10, &req); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Question) == "" {
|
|
WriteError(w, r, BadRequest("No question was supplied.",
|
|
FieldError{Field: "question", Message: "must not be empty"}))
|
|
return
|
|
}
|
|
if len(req.Question) > 2000 {
|
|
WriteError(w, r, BadRequest("The question is too long.",
|
|
FieldError{Field: "question", Message: "must be at most 2000 characters"}))
|
|
return
|
|
}
|
|
|
|
ar := search.AnswerRequest{Question: req.Question}
|
|
ar.ConfirmedOnly = req.ConfirmedOnly
|
|
ar.Limit = req.Limit
|
|
ar.EntityIDs = filterUUIDs(req.EntityIDs)
|
|
for _, st := range req.SourceTypes {
|
|
if s := domain.SourceType(st); s.Valid() {
|
|
ar.SourceTypes = append(ar.SourceTypes, s)
|
|
}
|
|
}
|
|
|
|
ans, err := h.d.Answer.Answer(r.Context(), p, ar)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
h.d.Audit.Record(r.Context(), p, domain.AuditAnswerGenerated, "answer", "", "success", map[string]string{
|
|
"retrieved": strconv.Itoa(ans.Retrieved), "sufficient": strconv.FormatBool(ans.Sufficient),
|
|
})
|
|
WriteJSON(w, http.StatusOK, ans)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListEntities returns a page of entities.
|
|
func (h *Handlers) ListEntities(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
f := domain.EntityFilter{
|
|
NameContains: strings.TrimSpace(r.URL.Query().Get("q")),
|
|
}
|
|
for _, t := range splitCSV(r.URL.Query().Get("type")) {
|
|
if et := domain.EntityType(t); et.Valid() {
|
|
f.Types = append(f.Types, et)
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("item_id"); v != "" {
|
|
if !ids.Valid(v) {
|
|
WriteError(w, r, BadRequest("item_id must be a UUID."))
|
|
return
|
|
}
|
|
f.ItemID = v
|
|
}
|
|
if v := r.URL.Query().Get("min_mentions"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
f.MinMentions = n
|
|
}
|
|
}
|
|
|
|
page := pageFromQuery(r)
|
|
ents, total, err := h.d.Entities.List(r.Context(), p.WorkspaceID, f, page)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"entities": ents,
|
|
"pagination": map[string]any{
|
|
"total": total, "limit": page.Limit, "offset": page.Offset,
|
|
"has_more": int64(page.Offset+len(ents)) < total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetEntity returns one entity with its aliases and citing items.
|
|
func (h *Handlers) GetEntity(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
ent, err := h.d.Entities.Get(r.Context(), p.WorkspaceID, id)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
aliases, _ := h.d.Aliases.ListByEntity(r.Context(), p.WorkspaceID, id)
|
|
items, _ := h.d.Items.GetMany(r.Context(), p.WorkspaceID, ent.ItemIDs)
|
|
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"entity": ent,
|
|
"aliases": aliases,
|
|
"items": items,
|
|
"note": "Candidate aliases are suggestions only. Relata never merges entities on name similarity alone.",
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Jobs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListJobs returns a page of background jobs.
|
|
func (h *Handlers) ListJobs(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var statuses []domain.JobStatus
|
|
for _, s := range splitCSV(r.URL.Query().Get("status")) {
|
|
switch domain.JobStatus(s) {
|
|
case domain.JobPending, domain.JobRunning, domain.JobSucceeded, domain.JobFailed, domain.JobCancelled:
|
|
statuses = append(statuses, domain.JobStatus(s))
|
|
}
|
|
}
|
|
var types []domain.JobType
|
|
for _, t := range splitCSV(r.URL.Query().Get("type")) {
|
|
if jt := domain.JobType(t); jt.Valid() {
|
|
types = append(types, jt)
|
|
}
|
|
}
|
|
|
|
page := pageFromQuery(r)
|
|
jobs, total, err := h.d.Jobs.List(r.Context(), p.WorkspaceID, statuses, types, page)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"jobs": jobs,
|
|
"pagination": map[string]any{
|
|
"total": total, "limit": page.Limit, "offset": page.Offset,
|
|
"has_more": int64(page.Offset+len(jobs)) < total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// RetryJob returns a failed job to the queue.
|
|
func (h *Handlers) RetryJob(w http.ResponseWriter, r *http.Request) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
job, err := h.d.Jobs.Retry(r.Context(), p.WorkspaceID, id)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
WriteError(w, r, &APIError{
|
|
Status: http.StatusConflict, Code: CodeConflict,
|
|
Message: "That job does not exist, or is not in a state that can be retried.",
|
|
})
|
|
return
|
|
}
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
h.d.Audit.Record(r.Context(), p, domain.AuditJobRetried, "job", id, "success",
|
|
map[string]string{"type": string(job.Type)})
|
|
WriteJSON(w, http.StatusOK, map[string]any{"job": job})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// relView adds computed fields to a relationship for API responses.
|
|
type relView struct {
|
|
*domain.Relationship
|
|
Explanation []string `json:"explanation"`
|
|
SourceTitle string `json:"source_title,omitempty"`
|
|
TargetTitle string `json:"target_title,omitempty"`
|
|
// ScoreMeaning states plainly what the number is, in every response that
|
|
// carries one.
|
|
ScoreMeaning string `json:"score_meaning"`
|
|
}
|
|
|
|
func relResponse(rel *domain.Relationship) map[string]any {
|
|
return map[string]any{"relationship": relViewOf(rel)}
|
|
}
|
|
|
|
func relViewOf(rel *domain.Relationship) *relView {
|
|
return &relView{
|
|
Relationship: rel,
|
|
Explanation: relationships.Explain(rel),
|
|
ScoreMeaning: "A heuristic relationship strength derived from the listed signals under " +
|
|
rel.ScoreVersion + ". It is not a calibrated probability.",
|
|
}
|
|
}
|
|
|
|
func withExplanations(rels []*domain.Relationship) []*relView {
|
|
out := make([]*relView, 0, len(rels))
|
|
for _, rel := range rels {
|
|
out = append(out, relViewOf(rel))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func withTitles(views []*relView, titles map[string]string) []*relView {
|
|
for _, v := range views {
|
|
v.SourceTitle = titles[v.SourceID]
|
|
v.TargetTitle = titles[v.TargetID]
|
|
}
|
|
return views
|
|
}
|
|
|
|
func pageFromQuery(r *http.Request) domain.Page {
|
|
p := domain.Page{}
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
p.Limit = n
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
p.Offset = n
|
|
}
|
|
}
|
|
return p.Normalise(20, 200)
|
|
}
|
|
|
|
// itemFilterFromQuery builds a typed filter from query parameters.
|
|
//
|
|
// Unrecognised values are rejected rather than ignored, so a client that
|
|
// mistypes a source type learns about it instead of silently getting
|
|
// unfiltered results.
|
|
func itemFilterFromQuery(r *http.Request) (domain.ItemFilter, error) {
|
|
q := r.URL.Query()
|
|
f := domain.ItemFilter{TitleContains: strings.TrimSpace(q.Get("title"))}
|
|
|
|
for _, s := range splitCSV(q.Get("source_type")) {
|
|
st := domain.SourceType(s)
|
|
if !st.Valid() {
|
|
return f, BadRequest("Unrecognised source type.",
|
|
FieldError{Field: "source_type", Message: s + " is not one of " + sourceTypeList()})
|
|
}
|
|
f.SourceTypes = append(f.SourceTypes, st)
|
|
}
|
|
f.MIMETypes = append(f.MIMETypes, splitCSV(q.Get("mime_type"))...)
|
|
for _, s := range splitCSV(q.Get("status")) {
|
|
f.Statuses = append(f.Statuses, domain.ProcessingStatus(s))
|
|
}
|
|
if v := strings.TrimSpace(q.Get("content_hash")); v != "" {
|
|
if !isHex(v) {
|
|
return f, BadRequest("content_hash must be a hexadecimal digest.")
|
|
}
|
|
f.ContentHash = v
|
|
}
|
|
if v := q.Get("created_from"); v != "" {
|
|
t, err := parseTime(v)
|
|
if err != nil {
|
|
return f, BadRequest("created_from must be an RFC 3339 date.")
|
|
}
|
|
f.CreatedFrom = &t
|
|
}
|
|
if v := q.Get("created_to"); v != "" {
|
|
t, err := parseTime(v)
|
|
if err != nil {
|
|
return f, BadRequest("created_to must be an RFC 3339 date.")
|
|
}
|
|
f.CreatedTo = &t
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func relFilterFromQuery(r *http.Request) (domain.RelationshipFilter, error) {
|
|
q := r.URL.Query()
|
|
f := domain.RelationshipFilter{Direction: q.Get("direction")}
|
|
|
|
for _, t := range splitCSV(q.Get("type")) {
|
|
rt := domain.RelationshipType(t)
|
|
if !rt.Valid() {
|
|
return f, BadRequest("Unrecognised relationship type.",
|
|
FieldError{Field: "type", Message: t + " is not a recognised relationship type"})
|
|
}
|
|
f.Types = append(f.Types, rt)
|
|
}
|
|
for _, pv := range splitCSV(q.Get("provenance")) {
|
|
p := domain.Provenance(pv)
|
|
if !p.Valid() {
|
|
return f, BadRequest("Unrecognised provenance.",
|
|
FieldError{Field: "provenance", Message: pv + " is not a recognised provenance"})
|
|
}
|
|
f.Provenances = append(f.Provenances, p)
|
|
}
|
|
for _, rs := range splitCSV(q.Get("review_status")) {
|
|
f.ReviewStatus = append(f.ReviewStatus, domain.ReviewStatus(rs))
|
|
}
|
|
if q.Get("confirmed_only") == "true" {
|
|
f.ConfirmedOnly = true
|
|
}
|
|
if v := q.Get("min_score"); v != "" {
|
|
n, err := strconv.ParseFloat(v, 64)
|
|
if err != nil || n < 0 || n > 1 {
|
|
return f, BadRequest("min_score must be a number between 0 and 1.")
|
|
}
|
|
f.MinScore = n
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func searchQueryFromRequest(r *http.Request) (search.Query, error) {
|
|
q := r.URL.Query()
|
|
out := search.Query{
|
|
Text: q.Get("q"),
|
|
ConfirmedOnly: q.Get("confirmed_only") == "true",
|
|
Expand: q.Get("expand") != "false",
|
|
}
|
|
if len(out.Text) > 2000 {
|
|
return out, BadRequest("The query is too long.",
|
|
FieldError{Field: "q", Message: "must be at most 2000 characters"})
|
|
}
|
|
|
|
for _, s := range splitCSV(q.Get("source_type")) {
|
|
st := domain.SourceType(s)
|
|
if !st.Valid() {
|
|
return out, BadRequest("Unrecognised source type.",
|
|
FieldError{Field: "source_type", Message: s + " is not one of " + sourceTypeList()})
|
|
}
|
|
out.SourceTypes = append(out.SourceTypes, st)
|
|
}
|
|
out.MIMETypes = append(out.MIMETypes, splitCSV(q.Get("mime_type"))...)
|
|
for _, t := range splitCSV(q.Get("relationship_type")) {
|
|
rt := domain.RelationshipType(t)
|
|
if !rt.Valid() {
|
|
return out, BadRequest("Unrecognised relationship type.",
|
|
FieldError{Field: "relationship_type", Message: t + " is not recognised"})
|
|
}
|
|
out.RelTypes = append(out.RelTypes, rt)
|
|
}
|
|
out.EntityIDs = filterUUIDs(splitCSV(q.Get("entity_id")))
|
|
|
|
if v := q.Get("created_from"); v != "" {
|
|
t, err := parseTime(v)
|
|
if err != nil {
|
|
return out, BadRequest("created_from must be an RFC 3339 date.")
|
|
}
|
|
out.CreatedFrom = &t
|
|
}
|
|
if v := q.Get("created_to"); v != "" {
|
|
t, err := parseTime(v)
|
|
if err != nil {
|
|
return out, BadRequest("created_to must be an RFC 3339 date.")
|
|
}
|
|
out.CreatedTo = &t
|
|
}
|
|
if v := q.Get("min_relationship_strength"); v != "" {
|
|
n, err := strconv.ParseFloat(v, 64)
|
|
if err != nil || n < 0 || n > 1 {
|
|
return out, BadRequest("min_relationship_strength must be between 0 and 1.")
|
|
}
|
|
out.MinRelStrength = n
|
|
}
|
|
if v := q.Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
out.Limit = n
|
|
}
|
|
}
|
|
if v := q.Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
if n > 0 {
|
|
out.Offset = n
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseTime accepts RFC 3339 or a plain date.
|
|
func parseTime(v string) (time.Time, error) {
|
|
v = strings.TrimSpace(v)
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
if t, err := time.Parse("2006-01-02", v); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
return time.Time{}, fmt.Errorf("unrecognised time %q", v)
|
|
}
|
|
|
|
func splitCSV(v string) []string {
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(v, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func filterUUIDs(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, v := range in {
|
|
if ids.Valid(v) {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isHex(s string) bool {
|
|
if len(s) == 0 || len(s) > 128 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sourceTypeList() string {
|
|
names := make([]string, 0, len(domain.ValidSourceTypes))
|
|
for _, s := range domain.ValidSourceTypes {
|
|
names = append(names, string(s))
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// metadataFromForm reads metadata[key]=value pairs from a multipart form.
|
|
func metadataFromForm(r *http.Request) map[string]string {
|
|
out := map[string]string{}
|
|
for k, vs := range r.Form {
|
|
if !strings.HasPrefix(k, "metadata[") || !strings.HasSuffix(k, "]") || len(vs) == 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSuffix(strings.TrimPrefix(k, "metadata["), "]")
|
|
if key != "" {
|
|
out[key] = vs[0]
|
|
}
|
|
}
|
|
// Also accept parallel metadata_key / metadata_value arrays from the form.
|
|
keys := r.Form["metadata_key"]
|
|
vals := r.Form["metadata_value"]
|
|
for i := range keys {
|
|
if i < len(vals) && strings.TrimSpace(keys[i]) != "" {
|
|
out[strings.TrimSpace(keys[i])] = vals[i]
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entity aliases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ConfirmAlias records that a person agrees two names refer to the same thing.
|
|
//
|
|
// Only a confirmed alias contributes evidence to relationship discovery. Until
|
|
// someone decides, a candidate is a suggestion and is weighted at nothing.
|
|
func (h *Handlers) ConfirmAlias(w http.ResponseWriter, r *http.Request) {
|
|
h.reviewAlias(w, r, domain.AliasConfirmed)
|
|
}
|
|
|
|
// RejectAlias records that a person judged two similar names to be different
|
|
// things. The proposal is retained, so it is not offered again as new.
|
|
func (h *Handlers) RejectAlias(w http.ResponseWriter, r *http.Request) {
|
|
h.reviewAlias(w, r, domain.AliasRejected)
|
|
}
|
|
|
|
func (h *Handlers) reviewAlias(w http.ResponseWriter, r *http.Request, status domain.AliasStatus) {
|
|
p, err := principal(r)
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if err := h.d.Aliases.SetStatus(r.Context(), p.WorkspaceID, id, status); err != nil {
|
|
WriteError(w, r, err)
|
|
return
|
|
}
|
|
h.d.Audit.Record(r.Context(), p, "entity_alias."+string(status), "entity_alias", id, "success", nil)
|
|
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"alias_id": id,
|
|
"status": status,
|
|
"note": "Entities are still stored separately. A confirmed alias contributes evidence to " +
|
|
"relationship discovery; it does not merge the two records.",
|
|
})
|
|
}
|