Files
2026-08-11 10:40:10 +01:00

859 lines
27 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/api"
"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"
)
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
// SearchPage runs a search, and optionally generates an answer.
func (h *Handlers) SearchPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
q := r.URL.Query()
text := strings.TrimSpace(q.Get("q"))
filters := map[string]string{
"CreatedFrom": q.Get("created_from"),
"CreatedTo": q.Get("created_to"),
"SourceType": q.Get("source_type"),
"MIMEType": q.Get("mime_type"),
"EntityID": q.Get("entity_id"),
"RelType": q.Get("relationship_type"),
"MinStrength": q.Get("min_relationship_strength"),
}
confirmedOnly := q.Get("confirmed_only") == "true"
// Entity options for the filter dropdown.
allEntities, _, _ := h.d.Entities.List(r.Context(), p.WorkspaceID,
domain.EntityFilter{MinMentions: 1}, domain.Page{Limit: 200})
data := struct {
base
Query search.Query
Filters map[string]string
FiltersConfirmed bool
HasFilters bool
Searched bool
Results *search.Response
Pagination pagination
AllEntities []*domain.Entity
AnswerEnabled bool
Answer *search.Answer
AnswerParagraphs []string
}{
base: h.newBase(r, "Search", "search"),
Filters: filters,
AllEntities: allEntities,
AnswerEnabled: h.d.Answer.Enabled(),
}
data.Filters["ConfirmedOnly"] = strconv.FormatBool(confirmedOnly)
for _, v := range filters {
if v != "" {
data.HasFilters = true
}
}
if confirmedOnly {
data.HasFilters = true
}
if text == "" && !data.HasFilters {
h.d.Renderer.Render(w, r, "search.html", http.StatusOK, data)
return
}
limit, offset := pageParams(r, h.d.Cfg.Search.DefaultLimit)
sq := search.Query{
Text: text,
ConfirmedOnly: confirmedOnly,
Expand: true,
Limit: limit,
Offset: offset,
}
if v := filters["SourceType"]; v != "" {
if s := domain.SourceType(v); s.Valid() {
sq.SourceTypes = []domain.SourceType{s}
}
}
if v := filters["MIMEType"]; v != "" {
sq.MIMETypes = []string{v}
}
if v := filters["EntityID"]; ids.Valid(v) {
sq.EntityIDs = []string{v}
}
if v := filters["RelType"]; v != "" {
if t := domain.RelationshipType(v); t.Valid() {
sq.RelTypes = []domain.RelationshipType{t}
}
}
if v := filters["CreatedFrom"]; v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
sq.CreatedFrom = &t
}
}
if v := filters["CreatedTo"]; v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
end := t.Add(24*time.Hour - time.Second)
sq.CreatedTo = &end
}
}
if v := filters["MinStrength"]; v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 && f <= 1 {
sq.MinRelStrength = f
}
}
data.Query = sq
data.Searched = true
// The answer mode is an explicit choice, not the default: generating an
// answer costs a model call and produces prose that must be read as a
// summary of sources rather than as an authority.
if q.Get("answer") == "true" && h.d.Answer.Enabled() && text != "" {
ans, err := h.d.Answer.Answer(r.Context(), p, search.AnswerRequest{Question: text, Query: sq})
if err != nil {
data.Error = "The answer could not be generated: " + friendlyError(err)
} else {
data.Answer = ans
data.AnswerParagraphs = splitParagraphs(ans.Answer)
h.d.Audit.Record(r.Context(), p, domain.AuditAnswerGenerated, "answer", "", "success",
map[string]string{"sufficient": strconv.FormatBool(ans.Sufficient)})
}
}
res, err := h.d.Search.Search(r.Context(), p, sq)
if err != nil {
data.Error = "The search could not be completed: " + friendlyError(err)
h.d.Renderer.Render(w, r, "search.html", http.StatusOK, data)
return
}
data.Results = res
data.Pagination = buildPagination(r, res.Total, limit, offset, len(res.Results))
h.d.Renderer.Render(w, r, "search.html", http.StatusOK, data)
}
// splitParagraphs breaks generated prose into paragraphs for rendering.
// The text is inserted as escaped content; splitting only affects layout.
func splitParagraphs(s string) []string {
var out []string
for _, p := range strings.Split(s, "\n\n") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// ---------------------------------------------------------------------------
// Add and upload
// ---------------------------------------------------------------------------
// AddTextPage shows the paste-a-note form.
func (h *Handlers) AddTextPage(w http.ResponseWriter, r *http.Request) {
data := struct {
base
Form map[string]string
}{
base: h.newBase(r, "Add text", "add"),
Form: map[string]string{"SourceType": string(domain.SourceTypeNote)},
}
if r.URL.Query().Get("error") != "" {
data.Error = knownFormError(r.URL.Query().Get("error"))
}
h.d.Renderer.Render(w, r, "add_text.html", http.StatusOK, data)
}
// AddText ingests pasted content.
func (h *Handlers) AddText(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if err := r.ParseForm(); err != nil {
h.renderFormError(w, r, "add_text.html", "Add text", "add", "The form could not be read.", nil)
return
}
req := ingest.Request{
Title: r.PostFormValue("title"),
Content: []byte(r.PostFormValue("content")),
SourceType: domain.SourceType(r.PostFormValue("source_type")),
Metadata: metadataFromForm(r),
AllowDuplicate: r.PostFormValue("allow_duplicate") == "true",
}
if v := r.PostFormValue("created_at"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
req.CreatedAt = &t
}
}
res, err := h.d.Ingest.Ingest(r.Context(), p, req)
if err != nil {
var dup *ingest.DuplicateError
if errors.As(err, &dup) {
h.renderFormError(w, r, "add_text.html", "Add text", "add",
fmt.Sprintf("An item with identical content already exists (%q). "+
"Tick “store even if identical content already exists” to keep both.", dup.ExistingTitle),
map[string]string{
"Title": r.PostFormValue("title"), "Content": r.PostFormValue("content"),
"SourceType": r.PostFormValue("source_type"), "CreatedAt": r.PostFormValue("created_at"),
})
return
}
h.renderFormError(w, r, "add_text.html", "Add text", "add", friendlyError(err),
map[string]string{
"Title": r.PostFormValue("title"), "Content": r.PostFormValue("content"),
"SourceType": r.PostFormValue("source_type"), "CreatedAt": r.PostFormValue("created_at"),
})
return
}
http.Redirect(w, r, "/items/"+res.Item.ID+"?ok=item_added", http.StatusSeeOther)
}
// UploadPage shows the file upload form.
func (h *Handlers) UploadPage(w http.ResponseWriter, r *http.Request) {
data := struct {
base
MaxUploadMB int64
Warning bool
}{
base: h.newBase(r, "Upload a document", "upload"),
MaxUploadMB: h.d.Cfg.Uploads.MaxUploadBytes / (1 << 20),
Warning: true,
}
h.d.Renderer.Render(w, r, "upload.html", http.StatusOK, data)
}
// Upload ingests a file.
func (h *Handlers) Upload(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
limit := h.d.Cfg.Uploads.MaxUploadBytes
renderErr := func(msg string) {
data := struct {
base
MaxUploadMB int64
Warning bool
}{
base: h.newBase(r, "Upload a document", "upload"),
MaxUploadMB: limit / (1 << 20),
Warning: true,
}
data.Error = msg
h.d.Renderer.Render(w, r, "upload.html", http.StatusBadRequest, data)
}
// The CSRF middleware has already parsed the multipart form, so the file is
// available without re-reading the body.
if r.MultipartForm == nil {
r.Body = http.MaxBytesReader(w, r.Body, limit+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
renderErr(fmt.Sprintf("The upload could not be read. Files must be no larger than %d MB.", limit/(1<<20)))
return
}
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
renderErr("No file was chosen.")
return
}
defer file.Close()
content := make([]byte, 0, 64<<10)
buf := make([]byte, 32<<10)
for {
n, rerr := file.Read(buf)
if n > 0 {
content = append(content, buf[:n]...)
if int64(len(content)) > limit {
renderErr(fmt.Sprintf("That file is larger than the %d MB limit.", limit/(1<<20)))
return
}
}
if rerr != nil {
break
}
}
req := ingest.Request{
Title: r.FormValue("title"),
Content: content,
Filename: header.Filename,
DeclaredMIME: header.Header.Get("Content-Type"),
SourceType: domain.SourceType(r.FormValue("source_type")),
Metadata: metadataFromForm(r),
AllowDuplicate: r.FormValue("allow_duplicate") == "true",
}
if v := r.FormValue("created_at"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
req.CreatedAt = &t
}
}
res, err := h.d.Ingest.Ingest(r.Context(), p, req)
if err != nil {
var dup *ingest.DuplicateError
if errors.As(err, &dup) {
renderErr(fmt.Sprintf("An item with identical content already exists (%q). "+
"Tick “store even if identical content already exists” to keep both.", dup.ExistingTitle))
return
}
renderErr(friendlyError(err))
return
}
http.Redirect(w, r, "/items/"+res.Item.ID+"?ok=item_added", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Item actions
// ---------------------------------------------------------------------------
// DeleteItem removes an item.
func (h *Handlers) DeleteItem(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
if _, err := h.d.Pipeline.RequestDeletion(r.Context(), p, id); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not delete the item", friendlyError(err))
return
}
http.Redirect(w, r, "/items?ok=item_deleted", http.StatusSeeOther)
}
// ReprocessItem queues an item for reprocessing.
func (h *Handlers) ReprocessItem(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
if _, err := h.d.Pipeline.RequestReprocess(r.Context(), p, id, "requested from the interface"); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not reprocess the item", friendlyError(err))
return
}
http.Redirect(w, r, "/items/"+id+"?ok=item_reprocessing", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Relationship review
// ---------------------------------------------------------------------------
// ReviewPage lists relationships for adjudication.
func (h *Handlers) ReviewPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
q := r.URL.Query()
status := q.Get("review_status")
if status == "" && !q.Has("review_status") {
status = string(domain.ReviewPending)
}
f := domain.RelationshipFilter{}
if status != "" {
f.ReviewStatus = []domain.ReviewStatus{domain.ReviewStatus(status)}
}
if t := q.Get("type"); t != "" {
if rt := domain.RelationshipType(t); rt.Valid() {
f.Types = []domain.RelationshipType{rt}
}
}
if v := q.Get("min_score"); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil && n >= 0 && n <= 1 {
f.MinScore = n
}
}
limit, offset := pageParams(r, 20)
rels, total, err := h.d.Rels.List(r.Context(), p.WorkspaceID, f, domain.Page{Limit: limit, Offset: offset})
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Could not load relationships", "Please try again.")
return
}
titles := h.titlesFor(r.Context(), p.WorkspaceID, rels)
data := struct {
base
Relationships []relView
Filters map[string]string
Pagination pagination
}{
base: h.newBase(r, "Relationship review", "review"),
Relationships: toRelViews(rels, titles),
Filters: map[string]string{
"Status": status, "Type": q.Get("type"), "MinScore": q.Get("min_score"),
},
Pagination: buildPagination(r, int(total), limit, offset, len(rels)),
}
h.d.Renderer.Render(w, r, "review.html", http.StatusOK, data)
}
// ConfirmRelationship records a confirmation.
func (h *Handlers) ConfirmRelationship(w http.ResponseWriter, r *http.Request) {
h.reviewAction(w, r, "confirm")
}
// RejectRelationship records a rejection.
func (h *Handlers) RejectRelationship(w http.ResponseWriter, r *http.Request) {
h.reviewAction(w, r, "reject")
}
// ChangeRelationshipType records a type correction.
func (h *Handlers) ChangeRelationshipType(w http.ResponseWriter, r *http.Request) {
h.reviewAction(w, r, "type")
}
func (h *Handlers) reviewAction(w http.ResponseWriter, r *http.Request, action string) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
note := r.PostFormValue("note")
var err error
var flashCode string
switch action {
case "confirm":
_, err = h.d.Reviewer.Confirm(r.Context(), p, id, note)
flashCode = "rel_confirmed"
case "reject":
_, err = h.d.Reviewer.Reject(r.Context(), p, id, note)
flashCode = "rel_rejected"
case "type":
newType := domain.RelationshipType(r.PostFormValue("type"))
_, err = h.d.Reviewer.ChangeType(r.Context(), p, id, newType, note)
flashCode = "rel_updated"
}
if err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not record the decision", friendlyError(err))
return
}
redirectBack(w, r, "/review", "?ok="+flashCode)
}
// CreateRelationship records a manually-asserted relationship.
func (h *Handlers) CreateRelationship(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
_ = r.ParseForm()
sourceID := r.PostFormValue("source_id")
targetID := r.PostFormValue("target_id")
relType := domain.RelationshipType(r.PostFormValue("type"))
if _, err := h.d.Reviewer.CreateManual(r.Context(), p, sourceID, targetID, relType, r.PostFormValue("note")); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not add the relationship", friendlyError(err))
return
}
http.Redirect(w, r, "/items/"+sourceID+"?ok=rel_created", http.StatusSeeOther)
}
// redirectBack returns to the referring page when it is one of ours.
//
// The Referer is only honoured when it is a same-origin path, so it cannot be
// used to bounce a user to another site.
func redirectBack(w http.ResponseWriter, r *http.Request, fallback, suffix string) {
target := fallback
if ref := r.Header.Get("Referer"); ref != "" {
if i := strings.Index(ref, "://"); i >= 0 {
rest := ref[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
path := rest[j:]
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
if q := strings.IndexByte(path, '?'); q >= 0 {
path = path[:q]
}
target = path
}
}
}
}
http.Redirect(w, r, target+suffix, http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------
// EntitiesPage lists extracted entities.
func (h *Handlers) EntitiesPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
q := r.URL.Query()
f := domain.EntityFilter{NameContains: strings.TrimSpace(q.Get("q"))}
if t := q.Get("type"); t != "" {
if et := domain.EntityType(t); et.Valid() {
f.Types = []domain.EntityType{et}
}
}
limit, offset := pageParams(r, 50)
ents, total, err := h.d.Entities.List(r.Context(), p.WorkspaceID, f, domain.Page{Limit: limit, Offset: offset})
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Could not list entities", "Please try again.")
return
}
data := struct {
base
Entities []*domain.Entity
Filters map[string]string
Pagination pagination
}{
base: h.newBase(r, "Entities", "entities"),
Entities: ents,
Filters: map[string]string{"Query": q.Get("q"), "Type": q.Get("type")},
Pagination: buildPagination(r, int(total), limit, offset, len(ents)),
}
h.d.Renderer.Render(w, r, "entities.html", http.StatusOK, data)
}
// EntityDetail shows one entity, its aliases and the items citing it.
func (h *Handlers) EntityDetail(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
h.renderError(w, r, http.StatusNotFound, "Entity not found", "That entity identifier is not valid.")
return
}
ent, err := h.d.Entities.Get(r.Context(), p.WorkspaceID, id)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Entity not found", "That entity does not exist in this workspace.")
return
}
aliases, _ := h.d.Aliases.ListByEntity(r.Context(), p.WorkspaceID, id)
items, _ := h.d.Items.GetMany(r.Context(), p.WorkspaceID, ent.ItemIDs)
data := struct {
base
Entity *domain.Entity
Aliases []*domain.EntityAlias
Items []*domain.Item
}{
base: h.newBase(r, ent.CanonicalName, "entities"),
Entity: ent,
Aliases: aliases,
Items: items,
}
h.d.Renderer.Render(w, r, "entity_detail.html", http.StatusOK, data)
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
type jobCounts struct{ Pending, Running, Succeeded, Failed int }
// JobsPage shows the processing queue.
func (h *Handlers) JobsPage(w http.ResponseWriter, r *http.Request) {
data, err := h.jobsData(r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Could not load the queue", "Please try again.")
return
}
h.d.Renderer.Render(w, r, "jobs.html", http.StatusOK, data)
}
// JobsTable renders just the queue table, for HTMX polling.
func (h *Handlers) JobsTable(w http.ResponseWriter, r *http.Request) {
data, err := h.jobsData(r)
if err != nil {
http.Error(w, "Could not load the queue", http.StatusInternalServerError)
return
}
h.d.Renderer.RenderPartial(w, r, "partial_jobs_table.html", data)
}
type jobsPage struct {
base
Jobs []*domain.Job
Counts jobCounts
}
func (h *Handlers) jobsData(r *http.Request) (jobsPage, error) {
p := api.PrincipalFrom(r.Context())
limit, offset := pageParams(r, 40)
jobs, _, err := h.d.Jobs.List(r.Context(), p.WorkspaceID, nil, nil,
domain.Page{Limit: limit, Offset: offset})
if err != nil {
return jobsPage{}, err
}
counts := jobCounts{}
if c, err := h.d.Jobs.CountByStatus(r.Context(), p.WorkspaceID); err == nil {
counts.Pending = int(c[domain.JobPending])
counts.Running = int(c[domain.JobRunning])
counts.Succeeded = int(c[domain.JobSucceeded])
counts.Failed = int(c[domain.JobFailed])
}
return jobsPage{
base: h.newBase(r, "Processing queue", "jobs"),
Jobs: jobs,
Counts: counts,
}, nil
}
// RetryJob returns a failed job to the queue.
func (h *Handlers) RetryJob(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
if _, err := h.d.Jobs.Retry(r.Context(), p.WorkspaceID, id); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not retry the job",
"That job does not exist, or it is not in a state that can be retried.")
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditJobRetried, "job", id, "success", nil)
http.Redirect(w, r, "/jobs?ok=job_retried", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
type weightRow struct {
Name string
Weight float64
}
// SettingsPage shows configuration, model status and the scoring model.
func (h *Handlers) SettingsPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
ctx := r.Context()
cfg := h.d.Cfg
var providers []domain.ProviderHealth
checkCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
if h.d.EmbedHealth != nil {
providers = append(providers, h.d.EmbedHealth.Health(checkCtx))
}
if h.d.AnalyseHealth != nil {
st := h.d.AnalyseHealth.Health(checkCtx)
if !cfg.Ollama.AnalysisEnabled {
st.Available = false
st.Detail = "Model-assisted analysis is disabled by configuration (RELATA_ANALYSIS_ENABLED=false)."
}
providers = append(providers, st)
}
modelVersions, _ := h.d.Models.List(ctx, p.WorkspaceID)
chunkCount, _ := h.d.Chunks.Count(ctx, p.WorkspaceID)
vectorCount, verr := h.d.Vectors.CountByWorkspace(ctx, p.WorkspaceID)
if verr != nil {
vectorCount = -1
}
weights := make([]weightRow, 0, len(relationships.Weights))
for _, t := range []domain.SignalType{
domain.SigSemanticSimilarity, domain.SigSharedEntity, domain.SigAliasEntity,
domain.SigSharedConcept, domain.SigTemporalProximity, domain.SigSharedMetadata,
domain.SigClaimSupport, domain.SigClaimContradiction, domain.SigModelClassify,
domain.SigContentHashMatch, domain.SigUserConfirmed, domain.SigUserRejected,
} {
weights = append(weights, weightRow{Name: string(t), Weight: relationships.Weights[t]})
}
v := currentVersions(h.d.Version, h.d.Embedder.Dimensions())
data := struct {
base
versionInfo
Providers []domain.ProviderHealth
ModelVersions []*domain.ModelVersion
Limits map[string]string
Storage map[string]any
Weights []weightRow
IsAdmin bool
OAuthEnabled bool
ProviderName string
}{
base: h.newBase(r, "Settings", "settings"),
versionInfo: v,
OAuthEnabled: h.oauthEnabled(),
ProviderName: h.providerName(),
Providers: providers,
ModelVersions: modelVersions,
Limits: map[string]string{
"MaxUpload": fmt.Sprintf("%d MB", cfg.Uploads.MaxUploadBytes/(1<<20)),
"MaxText": fmt.Sprintf("%d KB", cfg.Uploads.MaxExtractedTextBytes/1024),
"MaxJSONDepth": strconv.Itoa(cfg.Uploads.MaxJSONDepth),
"MaxChunks": strconv.Itoa(cfg.Chunking.MaxPerItem),
"ChunkMax": strconv.Itoa(cfg.Chunking.MaxChars),
"Workers": strconv.Itoa(cfg.Workers.Count),
"AIConcurrency": strconv.Itoa(cfg.Workers.AIConcurrency),
"ConcurrentIngest": strconv.Itoa(cfg.Uploads.MaxConcurrentIngest),
"RelCandidates": strconv.Itoa(cfg.Rel.MaxCandidates),
"RelStoreScore": fmt.Sprintf("%.2f", cfg.Rel.StoreScore),
"NearDuplicate": fmt.Sprintf("%.2f", cfg.Rel.NearDuplicateSimilarity),
"SessionIdle": cfg.Session.IdleTimeout.String(),
},
Storage: map[string]any{
"MongoDatabase": cfg.Mongo.Database,
"QdrantCollection": cfg.Qdrant.Collection,
"VectorCount": vectorCount,
"ChunkCount": chunkCount,
"UploadDir": cfg.Uploads.Dir,
"Mismatch": vectorCount >= 0 && vectorCount != chunkCount,
},
Weights: weights,
IsAdmin: p.Role == domain.RoleAdmin,
}
h.d.Renderer.Render(w, r, "settings.html", http.StatusOK, data)
}
// Reembed queues a workspace-wide re-embedding.
func (h *Handlers) Reembed(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if _, err := h.d.Pipeline.RequestReembed(r.Context(), p); err != nil {
h.renderError(w, r, http.StatusForbidden, "Could not queue re-embedding", friendlyError(err))
return
}
http.Redirect(w, r, "/settings?ok=reembed_queued", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// friendlyError converts an internal error into something safe to display.
//
// Only recognised, user-actionable conditions get specific text; anything else
// becomes a generic message so that internal detail does not reach the page.
func friendlyError(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrForbidden):
return "You do not have permission to do that."
case errors.Is(err, domain.ErrNotFound):
return "That record does not exist in this workspace."
case errors.Is(err, domain.ErrDuplicate):
return "Identical content already exists."
case errors.Is(err, domain.ErrTooLarge):
return "That content is larger than the configured limit."
case errors.Is(err, domain.ErrUnsupported):
return "That file type is not supported."
case errors.Is(err, search.ErrAnswerDisabled):
return "Answer generation requires a language model, which is not configured."
}
var ve *ingest.ValidationError
if errors.As(err, &ve) {
return ve.Message
}
var apiErr *api.APIError
if errors.As(err, &apiErr) && apiErr.Status < 500 {
return apiErr.Message
}
return "The request could not be completed. Please try again."
}
func knownFormError(code string) string {
switch code {
case "duplicate":
return "An item with identical content already exists."
case "too_large":
return "That content is larger than the configured limit."
}
return "The request could not be completed."
}
// renderFormError re-renders a form with an error and the user's input intact.
func (h *Handlers) renderFormError(w http.ResponseWriter, r *http.Request, page, title, nav, msg string, form map[string]string) {
if form == nil {
form = map[string]string{}
}
data := struct {
base
Form map[string]string
}{
base: h.newBase(r, title, nav),
Form: form,
}
data.Error = msg
h.d.Renderer.Render(w, r, page, http.StatusBadRequest, data)
}
// metadataFromForm reads parallel metadata_key/metadata_value form fields.
func metadataFromForm(r *http.Request) map[string]string {
keys := r.Form["metadata_key"]
vals := r.Form["metadata_value"]
out := map[string]string{}
for i, k := range keys {
k = strings.TrimSpace(k)
if k == "" || i >= len(vals) {
continue
}
out[k] = 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.
func (h *Handlers) ConfirmAlias(w http.ResponseWriter, r *http.Request) {
h.reviewAlias(w, r, domain.AliasConfirmed, "alias_confirmed")
}
// RejectAlias records that a person judged two similar names to be different.
func (h *Handlers) RejectAlias(w http.ResponseWriter, r *http.Request) {
h.reviewAlias(w, r, domain.AliasRejected, "alias_rejected")
}
func (h *Handlers) reviewAlias(w http.ResponseWriter, r *http.Request, status domain.AliasStatus, flashCode string) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
if err := h.d.Aliases.SetStatus(r.Context(), p.WorkspaceID, id, status); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Could not record the decision", friendlyError(err))
return
}
h.d.Audit.Record(r.Context(), p, "entity_alias."+string(status), "entity_alias", id, "success", nil)
redirectBack(w, r, "/entities", "?ok="+flashCode)
}