Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+697
View File
@@ -0,0 +1,697 @@
package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/api"
"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/observability"
"github.com/andydixon/relata/internal/parsing"
"github.com/andydixon/relata/internal/ranking"
"github.com/andydixon/relata/internal/relationships"
"github.com/andydixon/relata/internal/search"
)
// Deps bundles what the web handlers need.
type Deps struct {
Cfg *config.Config
Renderer *Renderer
Auth *auth.Service
// OAuth and Provisioner are nil when federated sign-in is not configured.
OAuth *auth.OAuthService
Provisioner *auth.Provisioner
Tokens *auth.TokenService
Version string
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
Users domain.UserRepository
Vectors domain.VectorStore
Ingest *ingest.Service
Pipeline *ingest.Pipeline
Search *search.Service
Answer *search.Answerer
Reviewer *relationships.Reviewer
Analysis *analysis.Service
Audit *audit.Recorder
Embedder domain.Embedder
EmbedHealth domain.HealthChecker
AnalyseHealth domain.HealthChecker
}
// Handlers serves the HTML interface.
type Handlers struct{ d Deps }
// NewHandlers builds the web handlers.
func NewHandlers(d Deps) *Handlers { return &Handlers{d: d} }
// base carries the fields every page template expects.
type base struct {
Title string
Nav string
Principal *domain.Principal
CSRFToken string
Flash *flash
Error string
PendingReview int64
RequestID string
}
type flash struct {
Kind string // success | warn | error | info
Message string
}
// newBase assembles the shared page context.
func (h *Handlers) newBase(r *http.Request, title, nav string) base {
b := base{Title: title, Nav: nav, RequestID: observability.RequestID(r.Context())}
b.Principal = api.PrincipalFrom(r.Context())
if s := api.SessionFrom(r.Context()); s != nil {
b.CSRFToken = s.CSRFToken
}
if b.Principal != nil {
if n, err := h.d.Rels.CountPendingReview(r.Context(), b.Principal.WorkspaceID); err == nil {
b.PendingReview = n
}
}
// A one-shot flash carried in the query string, set by a redirect after a
// successful action.
if msg := r.URL.Query().Get("ok"); msg != "" {
b.Flash = &flash{Kind: "success", Message: knownMessage(msg)}
}
if msg := r.URL.Query().Get("warn"); msg != "" {
b.Flash = &flash{Kind: "warn", Message: knownMessage(msg)}
}
return b
}
// knownMessage maps a short code to fixed text.
//
// Flash text is never taken from the query string directly: doing so would let
// a crafted link put arbitrary words on a trusted page.
func knownMessage(code string) string {
switch code {
case "item_added":
return "The item was stored and queued for processing."
case "item_deleted":
return "The item is being deleted along with its derived records and vectors."
case "item_reprocessing":
return "The item has been queued for reprocessing."
case "rel_confirmed":
return "Relationship confirmed. The original proposal and its evidence are retained."
case "rel_rejected":
return "Relationship rejected. It is kept on record and will no longer influence search."
case "rel_updated":
return "The relationship type was changed. The original proposal is retained."
case "rel_created":
return "The relationship was recorded."
case "job_retried":
return "The job has been returned to the queue."
case "alias_confirmed":
return "Alias confirmed. The entities remain separate records; the confirmation now counts as evidence when relationships are discovered."
case "alias_rejected":
return "Alias rejected. The proposal is kept on record so it is not offered again as new."
case "reembed_queued":
return "Re-embedding has been queued for this workspace."
case "duplicate":
return "An item with identical content already exists. Tick “store even if identical” to keep both."
case "signed_out":
return "You have been signed out."
case "token_revoked":
return "The token was revoked. It will be refused on its next use."
case "token_invalid":
return "The token could not be created. Check the name, the scopes and the expiry."
}
return "Done."
}
// renderError shows a friendly error page.
func (h *Handlers) renderError(w http.ResponseWriter, r *http.Request, status int, title, msg string) {
data := struct {
base
ErrorTitle string
ErrorMessage string
}{
base: h.newBase(r, title, ""),
ErrorTitle: title,
ErrorMessage: msg,
}
h.d.Renderer.Render(w, r, "error.html", status, data)
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
// LoginPage shows the sign-in form.
func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
// An already-authenticated visitor goes straight to the dashboard.
if p, _, err := h.d.Auth.Resolve(r.Context(), h.d.Auth.TokenFromRequest(r)); err == nil && p != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// The login form needs its own CSRF token before a session exists. One is
// issued in a short-lived cookie and checked on submission.
token, err := h.issuePreAuthToken(w)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Sign-in unavailable",
"A security token could not be generated. Please try again.")
return
}
firstRun := false
if n, err := h.d.Users.Count(r.Context()); err == nil && n == 0 {
firstRun = true
}
data := struct {
base
FirstRun bool
OAuthEnabled bool
LocalEnabled bool
ProviderName string
Next string
}{
base: base{Title: "Sign in", CSRFToken: token},
FirstRun: firstRun,
OAuthEnabled: h.oauthEnabled(),
LocalEnabled: h.localLoginEnabled(),
ProviderName: h.providerName(),
// Sanitised here rather than in the template: it becomes a URL.
Next: auth.SafeRedirectPath(r.URL.Query().Get("next")),
}
if data.Next == "/" {
data.Next = "" // nothing worth carrying
}
if r.URL.Query().Get("ok") == "signed_out" {
data.Flash = &flash{Kind: "success", Message: knownMessage("signed_out")}
}
if r.URL.Query().Get("error") == "1" {
data.Error = "The username or password is incorrect."
}
if r.URL.Query().Get("error") == "rate" {
data.Error = "Too many sign-in attempts. Please wait a few minutes and try again."
}
h.d.Renderer.Render(w, r, "login.html", http.StatusOK, data)
}
// preAuthCookie holds the CSRF token for the login form itself.
const preAuthCookie = "relata_login_csrf"
func (h *Handlers) issuePreAuthToken(w http.ResponseWriter) (string, error) {
token, err := ids.Token(32)
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: preAuthCookie,
Value: token,
Path: "/login",
MaxAge: 900,
HttpOnly: true,
Secure: h.d.Cfg.HTTP.SecureCookies,
SameSite: http.SameSiteLaxMode,
})
return token, nil
}
// Login processes the sign-in form.
//
// Failures are deliberately uniform: the same message and the same redirect,
// whether the username is unknown, the password is wrong or the account is
// disabled. Nothing about which one it was reaches the client.
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
// The route is not registered when local login is disabled, so this is
// belt and braces — but the guarantee should not rest on the router alone.
if !h.localLoginEnabled() {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
// Check the pre-authentication CSRF token.
cookie, err := r.Cookie(preAuthCookie)
if err != nil || !auth.CheckCSRF(cookie.Value, r.PostFormValue("csrf_token")) {
observability.LoggerFrom(r.Context()).Warn("login CSRF check failed")
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
username := r.PostFormValue("username")
password := r.PostFormValue("password")
rateKey := auth.ClientKey(r, h.d.Cfg.HTTP.TrustProxyHeaders)
res, err := h.d.Auth.Login(r.Context(), username, password, rateKey, r.UserAgent())
if err != nil {
h.d.Audit.RecordAnonymous(r.Context(), "", domain.AuditLoginFailed, "failure",
map[string]string{"reason": "invalid_credentials"})
if errors.Is(err, auth.ErrTooManyAttempts) {
http.Redirect(w, r, "/login?error=rate", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
// Clear the pre-auth cookie and issue the session cookie. The session ID is
// new, so no identifier survives across the authentication boundary.
http.SetCookie(w, &http.Cookie{
Name: preAuthCookie, Value: "", Path: "/login", MaxAge: -1,
HttpOnly: true, Secure: h.d.Cfg.HTTP.SecureCookies, SameSite: http.SameSiteLaxMode,
})
h.d.Auth.SetSessionCookie(w, res.Token, res.Session.ExpiresAt)
h.d.Audit.Record(r.Context(), &domain.Principal{
UserID: res.User.ID, Username: res.User.Username, WorkspaceID: res.User.WorkspaceID,
}, domain.AuditLogin, "user", res.User.ID, "success", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// Logout ends the session.
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
if s := api.SessionFrom(r.Context()); s != nil {
_ = h.d.Auth.Logout(r.Context(), s.ID)
h.d.Audit.Record(r.Context(), api.PrincipalFrom(r.Context()),
domain.AuditLogout, "session", s.ID, "success", nil)
}
h.d.Auth.ClearSessionCookie(w)
http.Redirect(w, r, "/login?ok=signed_out", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
type dashStats struct {
Items, Chunks, Entities, Relationships int64
PendingJobs, FailedJobs int
AwaitingReview int
}
type pendingRel struct {
Rel *domain.Relationship
SourceTitle string
TargetTitle string
}
// Dashboard shows workspace totals and what needs attention.
func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
ctx := r.Context()
stats := dashStats{}
stats.Items, _ = h.d.Items.Count(ctx, p.WorkspaceID)
stats.Chunks, _ = h.d.Chunks.Count(ctx, p.WorkspaceID)
stats.Entities, _ = h.d.Entities.Count(ctx, p.WorkspaceID)
stats.Relationships, _ = h.d.Rels.Count(ctx, p.WorkspaceID)
if counts, err := h.d.Jobs.CountByStatus(ctx, p.WorkspaceID); err == nil {
stats.PendingJobs = int(counts[domain.JobPending] + counts[domain.JobRunning])
stats.FailedJobs = int(counts[domain.JobFailed])
}
if n, err := h.d.Rels.CountPendingReview(ctx, p.WorkspaceID); err == nil {
stats.AwaitingReview = int(n)
}
items, _, _ := h.d.Items.List(ctx, p.WorkspaceID, domain.ItemFilter{}, domain.Page{Limit: 8})
rels, _, _ := h.d.Rels.List(ctx, p.WorkspaceID, domain.RelationshipFilter{
ReviewStatus: []domain.ReviewStatus{domain.ReviewPending},
}, domain.Page{Limit: 8})
pending := make([]pendingRel, 0, len(rels))
titles := h.titlesFor(ctx, p.WorkspaceID, rels)
for _, rel := range rels {
pending = append(pending, pendingRel{
Rel: rel, SourceTitle: titles[rel.SourceID], TargetTitle: titles[rel.TargetID],
})
}
data := struct {
base
Stats dashStats
RecentItems []*domain.Item
PendingRelationships []pendingRel
ProviderWarnings []string
}{
base: h.newBase(r, "Dashboard", "dashboard"),
Stats: stats,
RecentItems: items,
PendingRelationships: pending,
ProviderWarnings: h.providerWarnings(ctx),
}
h.d.Renderer.Render(w, r, "dashboard.html", http.StatusOK, data)
}
// providerWarnings reports any AI provider that is unreachable, so the
// dashboard can say plainly which features are degraded rather than leaving a
// user to wonder why entities stopped appearing.
func (h *Handlers) providerWarnings(ctx context.Context) []string {
var out []string
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if h.d.EmbedHealth != nil {
if st := h.d.EmbedHealth.Health(checkCtx); !st.Available {
msg := "The embedding model is unavailable, so new items cannot be indexed for meaning-based search."
if st.Detail != "" {
msg += " " + st.Detail
}
out = append(out, msg)
}
}
if h.d.AnalyseHealth != nil && h.d.Cfg.Ollama.AnalysisEnabled {
if st := h.d.AnalyseHealth.Health(checkCtx); !st.Available {
msg := "The analysis model is unavailable. Entity and claim extraction fall back to deterministic rules, and generated answers are disabled."
if st.Detail != "" {
msg += " " + st.Detail
}
out = append(out, msg)
}
}
return out
}
// titlesFor resolves item titles at both ends of a set of relationships.
func (h *Handlers) titlesFor(ctx context.Context, workspaceID string, rels []*domain.Relationship) map[string]string {
var idList []string
for _, rel := range rels {
idList = append(idList, rel.SourceID, rel.TargetID)
}
out := map[string]string{}
if len(idList) == 0 {
return out
}
items, err := h.d.Items.GetMany(ctx, workspaceID, idList)
if err != nil {
return out
}
for _, it := range items {
out[it.ID] = it.Title
}
return out
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
type pagination struct {
Total int
From, To int
HasMore bool
PrevURL, NextURL string
}
func buildPagination(r *http.Request, total, limit, offset, shown int) pagination {
p := pagination{Total: total, From: offset + 1, To: offset + shown}
if shown == 0 {
p.From = 0
}
p.HasMore = offset+shown < total
mk := func(newOffset int) string {
q := r.URL.Query()
q.Set("offset", strconv.Itoa(newOffset))
return r.URL.Path + "?" + q.Encode()
}
if offset > 0 {
prev := offset - limit
if prev < 0 {
prev = 0
}
p.PrevURL = mk(prev)
}
if p.HasMore {
p.NextURL = mk(offset + limit)
}
return p
}
// ItemsPage lists stored items.
func (h *Handlers) ItemsPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
q := r.URL.Query()
f := domain.ItemFilter{TitleContains: strings.TrimSpace(q.Get("title"))}
if st := q.Get("source_type"); st != "" {
if s := domain.SourceType(st); s.Valid() {
f.SourceTypes = []domain.SourceType{s}
}
}
if s := q.Get("status"); s != "" {
f.Statuses = []domain.ProcessingStatus{domain.ProcessingStatus(s)}
}
limit, offset := pageParams(r, 25)
items, total, err := h.d.Items.List(r.Context(), p.WorkspaceID, f, domain.Page{Limit: limit, Offset: offset})
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Could not list items", "Please try again.")
return
}
data := struct {
base
Items []*domain.Item
Filters map[string]string
Pagination pagination
}{
base: h.newBase(r, "Items", "items"),
Items: items,
Filters: map[string]string{
"Title": q.Get("title"), "SourceType": q.Get("source_type"), "Status": q.Get("status"),
},
Pagination: buildPagination(r, int(total), limit, offset, len(items)),
}
h.d.Renderer.Render(w, r, "items.html", http.StatusOK, data)
}
// ItemDetail shows one item and everything derived from it.
func (h *Handlers) ItemDetail(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, "Item not found", "That item identifier is not valid.")
return
}
ctx := r.Context()
item, err := h.d.Items.Get(ctx, p.WorkspaceID, id)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Item not found",
"That item does not exist in this workspace, or it has been deleted.")
return
}
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)
others, _, _ := h.d.Items.List(ctx, p.WorkspaceID, domain.ItemFilter{}, domain.Page{Limit: 100})
// Exclude this item from the "add a relationship" picker.
picker := make([]*domain.Item, 0, len(others))
for _, it := range others {
if it.ID != id {
picker = append(picker, it)
}
}
titles := h.titlesFor(ctx, p.WorkspaceID, append(append([]*domain.Relationship{}, outRels...), inRels...))
titles[item.ID] = item.Title
data := struct {
base
Item *domain.Item
Chunks []*domain.Chunk
Entities []*domain.Entity
Claims []*domain.Claim
Outgoing []relView
Incoming []relView
Runs []*domain.ProcessingRun
OtherItems []*domain.Item
RelationshipCount int
}{
base: h.newBase(r, item.Title, "items"),
Item: item,
Chunks: chunks,
Entities: ents,
Claims: claims,
Outgoing: toRelViews(outRels, titles),
Incoming: toRelViews(inRels, titles),
Runs: runs,
OtherItems: picker,
RelationshipCount: len(outRels) + len(inRels),
}
h.d.Renderer.Render(w, r, "item_detail.html", http.StatusOK, data)
}
// relView is a relationship with its endpoint titles resolved for display.
type relView struct {
Rel *domain.Relationship
SourceID string
TargetID string
SourceTitle string
TargetTitle string
}
func toRelViews(rels []*domain.Relationship, titles map[string]string) []relView {
out := make([]relView, 0, len(rels))
for _, rel := range rels {
out = append(out, relView{
Rel: rel,
SourceID: rel.SourceID, TargetID: rel.TargetID,
SourceTitle: titles[rel.SourceID], TargetTitle: titles[rel.TargetID],
})
}
return out
}
// Neighbourhood shows an item and its directly connected items.
func (h *Handlers) Neighbourhood(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, "Item not found", "That item identifier is not valid.")
return
}
ctx := r.Context()
item, err := h.d.Items.Get(ctx, p.WorkspaceID, id)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Item not found", "That item does not exist in this workspace.")
return
}
outRels, inRels, _ := h.d.Rels.ListForNode(ctx, p.WorkspaceID, domain.NodeItem, id, domain.RelationshipFilter{})
titles := h.titlesFor(ctx, p.WorkspaceID, append(append([]*domain.Relationship{}, outRels...), inRels...))
type neighbour struct {
Rel *domain.Relationship
OtherID string
OtherTitle string
}
mk := func(rels []*domain.Relationship, takeTarget bool) []neighbour {
out := make([]neighbour, 0, len(rels))
for _, rel := range rels {
other := rel.SourceID
if takeTarget {
other = rel.TargetID
}
out = append(out, neighbour{Rel: rel, OtherID: other, OtherTitle: titles[other]})
}
return out
}
data := struct {
base
Item *domain.Item
Outgoing []neighbour
Incoming []neighbour
}{
base: h.newBase(r, "Neighbourhood", "items"),
Item: item,
Outgoing: mk(outRels, true),
Incoming: mk(inRels, false),
}
h.d.Renderer.Render(w, r, "neighbourhood.html", http.StatusOK, data)
}
// DownloadOriginal serves an item's original uploaded file.
//
// The file is always sent as an attachment with a generic content type, so a
// browser downloads it rather than rendering it. That is what stops an uploaded
// HTML or SVG file executing script in Relata's origin.
func (h *Handlers) DownloadOriginal(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
item, err := h.d.Items.Get(r.Context(), p.WorkspaceID, id)
if err != nil {
http.NotFound(w, r)
return
}
content, err := h.d.Ingest.ReadOriginal(item)
if err != nil {
http.NotFound(w, r)
return
}
name := ingest.SanitiseFilename(item.OriginalFilename)
if name == "" {
name = "item-" + item.ID
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(name)))
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
_, _ = w.Write(content)
}
func pageParams(r *http.Request, def int) (limit, offset int) {
limit = def
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return limit, offset
}
// versionInfo is used by the settings page.
type versionInfo struct {
Version string
ParserVersion string
ScoreVersion string
RankVersion string
EmbeddingDims int
}
func currentVersions(appVersion string, dims int) versionInfo {
return versionInfo{
Version: appVersion,
ParserVersion: parsing.Version,
ScoreVersion: relationships.ScoreVersion,
RankVersion: ranking.RankVersion,
EmbeddingDims: dims,
}
}
+858
View File
@@ -0,0 +1,858 @@
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)
}
+242
View File
@@ -0,0 +1,242 @@
package web
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// oauthFlowCookie holds the per-attempt state, PKCE verifier and nonce.
//
// It is scoped to the callback path, HttpOnly and SameSite=Lax — Lax rather
// than Strict because the browser arrives here as a top-level navigation from
// the provider, and Strict would withhold the cookie exactly when it is needed.
const oauthFlowCookie = "relata_oauth_flow"
// oauthFlowTTL bounds how long a sign-in attempt may stay open.
const oauthFlowTTL = 15 * time.Minute
// OAuthStart begins federated sign-in.
func (h *Handlers) OAuthStart(w http.ResponseWriter, r *http.Request) {
if h.d.OAuth == nil || !h.d.OAuth.Enabled() {
h.renderError(w, r, http.StatusNotFound, "Not available",
"Federated sign-in is not configured on this instance.")
return
}
// Already signed in: nothing to do.
if p, _, err := h.d.Auth.Resolve(r.Context(), h.d.Auth.TokenFromRequest(r)); err == nil && p != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
url, fs, err := h.d.OAuth.AuthorizeURL(r.Context(), r.URL.Query().Get("next"))
if err != nil {
observability.LoggerFrom(r.Context()).Error("could not begin federated sign-in",
"error", err.Error())
h.renderError(w, r, http.StatusBadGateway, "Sign-in unavailable",
"The identity provider could not be reached. Please try again shortly.")
return
}
if err := h.setFlowCookie(w, fs); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Sign-in unavailable",
"A security token could not be generated. Please try again.")
return
}
http.Redirect(w, r, url, http.StatusSeeOther)
}
// OAuthCallback completes federated sign-in.
//
// Every failure renders the same generic page. The specifics — a mismatched
// state, a rejected token exchange, an unverifiable ID token — go to the log,
// because they describe the provider's configuration and an unauthenticated
// visitor has no business reading it.
func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) {
if h.d.OAuth == nil || !h.d.OAuth.Enabled() {
h.renderError(w, r, http.StatusNotFound, "Not available",
"Federated sign-in is not configured on this instance.")
return
}
log := observability.LoggerFrom(r.Context())
// The flow cookie is single-use whatever happens next.
fs, cookieErr := h.readFlowCookie(r)
h.clearFlowCookie(w)
// A provider that refused the request says so in the query string.
if e := r.URL.Query().Get("error"); e != "" {
log.Warn("identity provider refused the sign-in",
"provider_error", truncateForLog(e, 200),
"description", truncateForLog(r.URL.Query().Get("error_description"), 200))
h.oauthFailed(w, r, "The identity provider did not complete the sign-in.")
return
}
if cookieErr != nil {
log.Warn("federated sign-in had no usable flow state", "error", cookieErr.Error())
h.oauthFailed(w, r, "That sign-in attempt has expired. Please start again.")
return
}
identity, err := h.d.OAuth.Exchange(r.Context(), fs,
r.URL.Query().Get("state"), r.URL.Query().Get("code"))
if err != nil {
log.Warn("federated sign-in failed", "error", err.Error())
h.d.Audit.RecordAnonymous(r.Context(), "", domain.AuditOAuthFailed, "failure",
map[string]string{"reason": oauthFailureReason(err)})
switch {
case errors.Is(err, auth.ErrOAuthNotWanted):
h.oauthFailed(w, r, "That account is not permitted to use this instance.")
case errors.Is(err, auth.ErrOAuthState):
h.oauthFailed(w, r, "That sign-in attempt could not be verified. Please start again.")
default:
h.oauthFailed(w, r, "Sign-in could not be completed. Please try again shortly.")
}
return
}
res, err := h.d.Provisioner.SignIn(r.Context(), h.d.OAuth.ProviderName(), identity, r.UserAgent())
if err != nil {
log.Error("could not establish a session after federated sign-in", "error", err.Error())
h.d.Audit.RecordAnonymous(r.Context(), "", domain.AuditOAuthFailed, "failure",
map[string]string{"reason": "provisioning"})
h.oauthFailed(w, r, "Your account could not be prepared. Please try again shortly.")
return
}
h.d.Auth.SetSessionCookie(w, res.Token, res.Session.ExpiresAt)
h.d.Audit.Record(r.Context(), &domain.Principal{
UserID: res.User.ID, Username: res.User.Username, WorkspaceID: res.User.WorkspaceID,
}, domain.AuditOAuthLogin, "user", res.User.ID, "success", nil)
// The destination was reduced to a local path when the flow began, so it
// cannot send the browser off-site here.
dest := auth.SafeRedirectPath(fs.RedirectPath)
http.Redirect(w, r, dest, http.StatusSeeOther)
}
func (h *Handlers) oauthFailed(w http.ResponseWriter, r *http.Request, msg string) {
h.renderError(w, r, http.StatusUnauthorized, "Sign-in failed", msg)
}
// oauthFailureReason maps an error to a bounded audit label, so the audit trail
// stays queryable and never accumulates provider text as unbounded cardinality.
func oauthFailureReason(err error) string {
switch {
case errors.Is(err, auth.ErrOAuthState):
return "state_mismatch"
case errors.Is(err, auth.ErrOAuthNotWanted):
return "not_permitted"
case errors.Is(err, auth.ErrOAuthIdentity):
return "invalid_identity"
case errors.Is(err, auth.ErrOAuthProvider):
return "provider_error"
case errors.Is(err, auth.ErrOAuthDisabled):
return "disabled"
}
return "unknown"
}
// ---------------------------------------------------------------------------
// Flow cookie
// ---------------------------------------------------------------------------
func (h *Handlers) setFlowCookie(w http.ResponseWriter, fs *auth.FlowState) error {
raw, err := json.Marshal(fs)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: oauthFlowCookie,
Value: base64.RawURLEncoding.EncodeToString(raw),
// Scoped to the OAuth endpoints: it has no business being attached to
// any other request.
Path: "/auth/oauth",
HttpOnly: true,
Secure: h.d.Cfg.HTTP.SecureCookies,
SameSite: http.SameSiteLaxMode,
MaxAge: int(oauthFlowTTL.Seconds()),
})
return nil
}
func (h *Handlers) readFlowCookie(r *http.Request) (*auth.FlowState, error) {
c, err := r.Cookie(oauthFlowCookie)
if err != nil {
return nil, errors.New("no sign-in flow cookie")
}
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil {
return nil, errors.New("sign-in flow cookie is malformed")
}
// The cookie is our own, but it arrives from the browser, so it is decoded
// strictly like any other untrusted input.
dec := json.NewDecoder(newLimitedReader(raw, 4<<10))
dec.DisallowUnknownFields()
var fs auth.FlowState
if err := dec.Decode(&fs); err != nil {
return nil, errors.New("sign-in flow cookie is not valid")
}
if fs.State == "" || fs.Verifier == "" {
return nil, errors.New("sign-in flow cookie is incomplete")
}
return &fs, nil
}
func (h *Handlers) clearFlowCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: oauthFlowCookie, Value: "", Path: "/auth/oauth", MaxAge: -1,
HttpOnly: true, Secure: h.d.Cfg.HTTP.SecureCookies, SameSite: http.SameSiteLaxMode,
})
}
// oauthEnabled reports whether the sign-in button should be shown.
func (h *Handlers) oauthEnabled() bool {
return h.d.OAuth != nil && h.d.OAuth.Enabled()
}
// localLoginEnabled reports whether the password form should be shown.
func (h *Handlers) localLoginEnabled() bool {
return h.d.Cfg != nil && h.d.Cfg.Auth.LocalLoginEnabled
}
// providerName is the label for the sign-in button.
func (h *Handlers) providerName() string {
if h.d.OAuth == nil {
return ""
}
return h.d.OAuth.ProviderName()
}
// callbackPath is exported to the settings page so an operator can see exactly
// what to register with the provider.
func (h *Handlers) callbackURL() string {
if h.d.Cfg == nil {
return config.CallbackPath
}
return h.d.Cfg.RedirectURI()
}
func truncateForLog(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// newLimitedReader wraps a byte slice so the JSON decoder cannot be handed more
// than the caller intended, even if the cookie is enormous.
func newLimitedReader(b []byte, max int64) *io.LimitedReader {
return &io.LimitedReader{R: bytes.NewReader(b), N: max}
}
+115
View File
@@ -0,0 +1,115 @@
package web
import (
"net/http"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/relationships"
)
// publicBase carries what every public page needs.
//
// It deliberately does not embed `base`: the shared page context reads the
// pending-review count from the database, and a page served to an anonymous
// visitor must touch no workspace data at all.
type publicBase struct {
Title string
Version string
SignedIn bool
OAuthEnabled bool
LocalEnabled bool
ProviderName string
RequestID string
}
func (h *Handlers) newPublicBase(r *http.Request, title string) publicBase {
return publicBase{
Title: title,
Version: h.d.Version,
SignedIn: api.PrincipalFrom(r.Context()) != nil,
OAuthEnabled: h.oauthEnabled(),
LocalEnabled: h.localLoginEnabled(),
ProviderName: h.providerName(),
}
}
// LandingPage is the only dual-purpose route.
//
// Signed in, it is the dashboard. Signed out, it is a description of what
// Relata does — served rather than redirected, so the front page of the site is
// not a login form.
func (h *Handlers) LandingPage(w http.ResponseWriter, r *http.Request) {
if api.PrincipalFrom(r.Context()) != nil {
h.Dashboard(w, r)
return
}
data := struct {
publicBase
Weights []publicWeightRow
}{
publicBase: h.newPublicBase(r, "Relata — a self-organising semantic document database"),
Weights: publicWeightRows(),
}
h.d.Renderer.RenderPublic(w, r, "public_landing.html", http.StatusOK, data)
}
// HowItWorksPage explains the pipeline and the scoring, without any data.
func (h *Handlers) HowItWorksPage(w http.ResponseWriter, r *http.Request) {
data := struct {
publicBase
Weights []publicWeightRow
}{
publicBase: h.newPublicBase(r, "How Relata works"),
Weights: publicWeightRows(),
}
h.d.Renderer.RenderPublic(w, r, "public_how.html", http.StatusOK, data)
}
// PublicAPIPage describes the API and how to authenticate against it.
func (h *Handlers) PublicAPIPage(w http.ResponseWriter, r *http.Request) {
data := struct {
publicBase
CallbackURL string
}{
publicBase: h.newPublicBase(r, "The Relata API"),
CallbackURL: h.callbackURL(),
}
h.d.Renderer.RenderPublic(w, r, "public_api.html", http.StatusOK, data)
}
// publicWeightRow is one signal weight, shown publicly because the scoring model is
// part of what the product claims rather than something to be discovered.
type publicWeightRow struct {
Signal string
Weight float64
Means string
}
func publicWeightRows() []publicWeightRow {
explain := map[string]string{
"content_hash_match": "Byte-identical content.",
"semantic_similarity": "How alike the wording is, rebased so that ordinary similarity counts for little.",
"shared_entity": "An exact entity match, discounted by how common that entity is.",
"claim_support": "Two statements agree about the same subject.",
"claim_contradiction": "Two statements disagree about the same subject.",
"model_classification": "A language model's opinion. One signal, weighted below the storage " +
"threshold, so it can never create a relationship on its own.",
"shared_concept": "Overlapping concept vocabulary.",
"alias_entity": "A match through an alias a person confirmed. Unconfirmed candidates count for nothing.",
"temporal_proximity": "Created close together in time.",
"shared_metadata": "Matching metadata you supplied.",
"user_confirmation": "A person confirmed it. This dominates everything else.",
"user_rejection": "A person rejected it. This drives the score to zero.",
}
rows := make([]publicWeightRow, 0, len(relationships.Weights))
for _, sig := range relationships.WeightOrder() {
rows = append(rows, publicWeightRow{
Signal: string(sig),
Weight: relationships.Weights[sig],
Means: explain[string(sig)],
})
}
return rows
}
+339
View File
@@ -0,0 +1,339 @@
// Package web serves Relata's server-rendered HTML interface.
//
// Every value interpolated into a page passes through html/template's
// contextual auto-escaping. Stored content — titles, excerpts, entity names,
// model output — is treated as untrusted text and is never marked safe. There
// is deliberately no helper in this package that produces template.HTML from a
// stored string, because such a helper is how stored XSS gets introduced.
package web
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// StaticFS exposes the embedded static assets for mounting, rooted at the
// assets themselves rather than at the directory containing them.
//
// The embed directive captures paths as "static/app.css", while the router
// strips the "/static/" prefix before looking a file up. Without this the two
// cancel out the wrong way and every asset 404s — which the browser reports
// not as a missing stylesheet but as corrupted content, because the 404 body
// arrives as text/plain under X-Content-Type-Options: nosniff.
func StaticFS() fs.FS {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
// Only reachable if the embed directive above stops matching, which is
// a build-time mistake rather than a runtime condition.
panic("static assets are not embedded: " + err.Error())
}
return sub
}
// Renderer holds the parsed templates.
type Renderer struct {
templates map[string]*template.Template
}
// pages are the content templates, each combined with the layout.
var pages = []string{
"login.html",
"dashboard.html",
"items.html",
"add_text.html",
"upload.html",
"item_detail.html",
"search.html",
"review.html",
"entities.html",
"entity_detail.html",
"jobs.html",
"settings.html",
"error.html",
"neighbourhood.html",
"tokens.html",
}
// publicPages are served to anyone. They use their own layout, which has no
// navigation, no workspace context and nothing that reads the database — so a
// signed-out visitor cannot be shown stored content by a template mistake.
var publicPages = []string{
"public_landing.html",
"public_how.html",
"public_api.html",
}
// partials are fragments rendered on their own for HTMX swaps.
var partials = []string{
"partial_search_results.html",
"partial_relationship.html",
"partial_jobs_table.html",
"partial_answer.html",
}
// NewRenderer parses every template at startup, so a template error is a
// start-up failure rather than a broken page at request time.
func NewRenderer() (*Renderer, error) {
r := &Renderer{templates: map[string]*template.Template{}}
for _, page := range pages {
t, err := template.New("layout.html").Funcs(funcs()).ParseFS(templateFS,
"templates/layout.html", "templates/"+page, "templates/partial_*.html")
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", page, err)
}
r.templates[page] = t
}
for _, page := range publicPages {
t, err := template.New("public_layout.html").Funcs(funcs()).ParseFS(templateFS,
"templates/public_layout.html", "templates/"+page)
if err != nil {
return nil, fmt.Errorf("parse public template %s: %w", page, err)
}
r.templates[page] = t
}
for _, p := range partials {
t, err := template.New(p).Funcs(funcs()).ParseFS(templateFS, "templates/"+p)
if err != nil {
return nil, fmt.Errorf("parse partial %s: %w", p, err)
}
r.templates[p] = t
}
return r, nil
}
// Render writes a full page.
//
// The page is rendered into a buffer first: a template error midway through
// would otherwise leave a half-written page with a 200 status, which looks to a
// user like data loss.
func (r *Renderer) Render(w http.ResponseWriter, req *http.Request, page string, status int, data any) {
t, ok := r.templates[page]
if !ok {
observability.LoggerFrom(req.Context()).Error("unknown template requested", "template", page)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout.html", data); err != nil {
observability.LoggerFrom(req.Context()).Error("template rendering failed",
"template", page, "error", err.Error())
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
// RenderPublic writes a page from the public layout.
//
// It is a separate method rather than a flag on Render so that a public page
// cannot accidentally be rendered into the application layout, which expects a
// principal and a workspace.
func (r *Renderer) RenderPublic(w http.ResponseWriter, req *http.Request, page string, status int, data any) {
t, ok := r.templates[page]
if !ok {
observability.LoggerFrom(req.Context()).Error("unknown public template requested", "template", page)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "public_layout.html", data); err != nil {
observability.LoggerFrom(req.Context()).Error("public template rendering failed",
"template", page, "error", err.Error())
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
// RenderPartial writes a fragment, for HTMX swaps.
func (r *Renderer) RenderPartial(w http.ResponseWriter, req *http.Request, name string, data any) {
t, ok := r.templates[name]
if !ok {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, name, data); err != nil {
observability.LoggerFrom(req.Context()).Error("partial rendering failed",
"template", name, "error", err.Error())
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
// funcs are the template helpers.
//
// None of them returns template.HTML from stored content. `percent` and
// `strengthLabel` format numbers; `truncate` shortens text; all output remains
// a plain string that the template engine escapes on insertion.
func funcs() template.FuncMap {
return template.FuncMap{
"percent": func(v float64) string {
return fmt.Sprintf("%.0f%%", v*100)
},
"score2": func(v float64) string {
return fmt.Sprintf("%.2f", v)
},
// strengthLabel turns a score into words, so a user is not left to
// interpret a bare decimal as if it were a probability.
"strengthLabel": func(v float64) string {
switch {
case v >= 0.75:
return "strong"
case v >= 0.5:
return "moderate"
case v >= 0.3:
return "weak"
default:
return "very weak"
}
},
"provenanceLabel": provenanceLabel,
"relTypeLabel": relTypeLabel,
"statusLabel": statusLabel,
"truncate": func(n int, s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 {
n--
}
return s[:n] + "…"
},
"datetime": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("2 Jan 2006, 15:04")
},
"date": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("2 Jan 2006")
},
"ago": func(t time.Time) string {
if t.IsZero() {
return "—"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
},
"bytes": func(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
"seq": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
"dict": func(values ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(values); i += 2 {
if k, ok := values[i].(string); ok {
m[k] = values[i+1]
}
}
return m
},
"relTypes": func() []domain.RelationshipType { return domain.ValidRelationshipTypes },
"sourceTypes": func() []domain.SourceType { return domain.ValidSourceTypes },
"entityTypes": func() []domain.EntityType { return domain.ValidEntityTypes },
}
}
func provenanceLabel(p domain.Provenance) string {
switch p {
case domain.ProvExplicit:
return "stated by a person"
case domain.ProvDeterministic:
return "derived from matching evidence"
case domain.ProvModelInferred:
return "suggested by analysis — not verified"
case domain.ProvUserConfirmed:
return "confirmed by a person"
case domain.ProvUserRejected:
return "rejected by a person"
}
return string(p)
}
func relTypeLabel(t domain.RelationshipType) string {
return strings.ReplaceAll(string(t), "_", " ")
}
func statusLabel(s domain.ProcessingStatus) string {
switch s {
case domain.StatusPending:
return "Waiting"
case domain.StatusParsing:
return "Extracting text"
case domain.StatusChunking:
return "Dividing into sections"
case domain.StatusEmbedding:
return "Generating embeddings"
case domain.StatusAnalysing:
return "Extracting entities and claims"
case domain.StatusRelating:
return "Finding relationships"
case domain.StatusReady:
return "Ready"
case domain.StatusPartial:
return "Ready, with warnings"
case domain.StatusFailed:
return "Failed"
case domain.StatusTombstoned:
return "Deleted"
}
return string(s)
}
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"net/http/httptest"
"strings"
"testing"
)
// Templates are parsed at startup, so a syntax error is a start-up failure
// rather than a broken page. This makes it a test failure instead.
func TestEveryTemplateParses(t *testing.T) {
r, err := NewRenderer()
if err != nil {
t.Fatalf("templates do not parse: %v", err)
}
for _, name := range append(append([]string{}, pages...), publicPages...) {
if r.templates[name] == nil {
t.Errorf("template %q was not registered", name)
}
}
for _, name := range partials {
if r.templates[name] == nil {
t.Errorf("partial %q was not registered", name)
}
}
}
// A public page must render from the public layout, which has no navigation and
// no workspace context — and must not require a principal to do it.
func TestPublicPagesRenderWithoutAPrincipal(t *testing.T) {
r, err := NewRenderer()
if err != nil {
t.Fatal(err)
}
data := struct {
publicBase
Weights []publicWeightRow
CallbackURL string
}{
publicBase: publicBase{
Title: "Test", Version: "test", SignedIn: false,
OAuthEnabled: true, ProviderName: "Blackwall",
},
Weights: publicWeightRows(),
CallbackURL: "https://relata.example/auth/oauth/callback",
}
for _, page := range publicPages {
t.Run(page, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
r.RenderPublic(w, req, page, 200, data)
if w.Code != 200 {
t.Fatalf("status = %d; the page failed to render", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "<!DOCTYPE html>") {
t.Fatal("no document was produced")
}
// The application chrome belongs to signed-in pages only.
for _, leak := range []string{`href="/review"`, `href="/jobs"`, `action="/logout"`} {
if strings.Contains(body, leak) {
t.Errorf("public page contains application navigation (%s)", leak)
}
}
})
}
}
// The weight table is rendered from the live map, so it cannot drift from the
// scoring model — and it must be in a fixed order or the page changes on every
// reload for no reason.
func TestPublicWeightRowsAreCompleteAndOrdered(t *testing.T) {
first := publicWeightRows()
if len(first) == 0 {
t.Fatal("no weights were produced")
}
for _, row := range first {
if row.Means == "" {
t.Errorf("signal %q has no explanation; the public page would show a blank cell", row.Signal)
}
}
for i := 0; i < 5; i++ {
again := publicWeightRows()
for j := range first {
if again[j].Signal != first[j].Signal {
t.Fatal("weight order is not stable between calls")
}
}
}
}
+553
View File
@@ -0,0 +1,553 @@
/* Relata interface styles.
Design constraints, deliberately: readable body text at 16px or larger, no
text over images, no decorative animation, visible focus outlines, and
layouts that reflow rather than scroll horizontally. */
:root {
--ink: #16191d;
--ink-soft: #4a525c;
--ink-faint: #6b7480;
--paper: #ffffff;
--surface: #f6f7f9;
--surface-alt: #eef0f4;
--line: #d3d8df;
--accent: #1f5f8b;
--accent-dark: #164961;
--ok: #1c6b41;
--ok-bg: #e6f4ec;
--warn: #8a5a00;
--warn-bg: #fdf3e0;
--danger: #a02020;
--danger-bg: #fbeaea;
--attention: #6b3fa0;
--radius: 6px;
--gap: 1rem;
--measure: 72ch;
}
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.55;
color: var(--ink);
background: var(--surface);
}
/* Keyboard access ------------------------------------------------------- */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--accent);
color: #fff;
padding: 0.75rem 1rem;
z-index: 100;
}
.skip-link:focus { left: 0; }
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Masthead -------------------------------------------------------------- */
.masthead {
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.masthead-inner {
max-width: 78rem;
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gap);
}
.brand {
font-weight: 700;
font-size: 1.25rem;
color: var(--ink);
text-decoration: none;
letter-spacing: -0.01em;
}
.nav-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
list-style: none;
margin: 0;
padding: 0;
flex: 1;
}
.nav-list a {
color: var(--ink-soft);
text-decoration: none;
padding: 0.35rem 0.15rem;
border-bottom: 2px solid transparent;
display: inline-block;
}
.nav-list a:hover { color: var(--ink); border-bottom-color: var(--line); }
.nav-list a[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.signout { display: flex; align-items: center; gap: 0.5rem; margin: 0; }
.whoami { color: var(--ink-faint); font-size: 0.9375rem; }
.badge {
display: inline-block;
min-width: 1.4em;
padding: 0 0.35em;
border-radius: 999px;
background: var(--attention);
color: #fff;
font-size: 0.8125rem;
text-align: center;
}
/* Page ------------------------------------------------------------------ */
.page {
max-width: 78rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
h1 { font-size: 1.75rem; line-height: 1.25; margin: 0 0 1rem; }
h2 { font-size: 1.3125rem; line-height: 1.3; margin: 2rem 0 0.75rem; }
h3 { font-size: 1.0625rem; margin: 1.5rem 0 0.5rem; }
h4 { font-size: 1rem; margin: 1rem 0 0.4rem; }
p { max-width: var(--measure); }
.muted { color: var(--ink-faint); }
.hint { color: var(--ink-faint); font-size: 0.9375rem; margin: 0.25rem 0 0; }
a { color: var(--accent); }
a:hover { color: var(--accent-dark); }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
background: var(--surface-alt);
padding: 0.1em 0.35em;
border-radius: 3px;
}
pre {
background: var(--surface-alt);
padding: 0.75rem 1rem;
border-radius: var(--radius);
overflow-x: auto;
font-size: 0.9375rem;
}
pre code { background: none; padding: 0; }
/* Flashes and callouts -------------------------------------------------- */
.flash {
padding: 0.75rem 1rem;
border-radius: var(--radius);
border-left: 4px solid;
margin: 0 0 1.25rem;
max-width: var(--measure);
}
.flash-success { background: var(--ok-bg); border-color: var(--ok); color: #124b2d; }
.flash-error { background: var(--danger-bg); border-color: var(--danger); color: #7a1818; }
.flash-warn { background: var(--warn-bg); border-color: var(--warn); color: #6b4600; }
.flash-info { background: var(--surface-alt); border-color: var(--accent); }
.callout {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
.callout-warn { background: var(--warn-bg); border-color: #e8cf9c; }
.callout h2, .callout h3 { margin-top: 0; }
/* Buttons and forms ----------------------------------------------------- */
.btn {
display: inline-block;
padding: 0.5rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
color: var(--ink);
font: inherit;
font-size: 0.9375rem;
text-decoration: none;
cursor: pointer;
}
.btn:hover { background: var(--surface-alt); color: var(--ink); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn-primary:hover { background: var(--accent-dark); color: #fff; }
.btn-danger { color: var(--danger); border-color: #e3b6b6; }
.btn-danger:hover { background: var(--danger-bg); }
.btn-ok { color: var(--ok); border-color: #b3ddc4; }
.btn-ok:hover { background: var(--ok-bg); }
.btn-quiet { border-color: transparent; background: none; color: var(--ink-soft); }
.btn-small { padding: 0.25rem 0.55rem; font-size: 0.875rem; }
.stack { display: flex; flex-direction: column; gap: 1rem; max-width: 46rem; }
.row { display: flex; flex-wrap: wrap; gap: 1rem; }
.row > .field { flex: 1 1 14rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field-wide { max-width: none; }
label { font-weight: 600; font-size: 0.9375rem; }
input[type="text"], input[type="search"], input[type="password"],
input[type="date"], input[type="number"], input[type="file"],
select, textarea {
font: inherit;
font-size: 1rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
color: var(--ink);
width: 100%;
max-width: 46rem;
}
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9375rem; resize: vertical; }
.field-check { flex-direction: row; align-items: center; gap: 0.5rem; }
.field-check input { width: auto; }
.field-check label { font-weight: 400; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
legend { font-weight: 600; padding: 0 0.4rem; }
.inline-form { display: inline-flex; gap: 0.4rem; align-items: center; margin: 0; flex-wrap: wrap; }
.inline-form input[type="text"] { width: auto; min-width: 12rem; }
.inline-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; margin-top: 1rem; }
.inline-grid .field { flex: 1 1 14rem; }
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1.5rem;
}
.filter-bar .field { flex: 0 1 14rem; }
/* Search ---------------------------------------------------------------- */
.search-form {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.search-form input[type="search"] { max-width: none; font-size: 1.0625rem; }
.search-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
.filters { margin-top: 1rem; }
.filters summary { cursor: pointer; font-weight: 600; padding: 0.35rem 0; }
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
margin-top: 0.75rem;
}
.score-key {
background: var(--surface-alt);
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.9375rem;
max-width: none;
}
.result-list { list-style: none; padding: 0; margin: 0; counter-reset: result; }
.result {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.result h3 { margin: 0 0 0.4rem; font-size: 1.125rem; }
.result-scores { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0 0 0.6rem; font-size: 0.9375rem; max-width: none; }
.metric { color: var(--ink-soft); }
.excerpt { margin: 0.5rem 0; color: var(--ink-soft); max-width: none; }
.via-note {
background: var(--warn-bg);
border-left: 3px solid var(--warn);
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
margin: 0.5rem 0;
max-width: none;
}
.why { margin-top: 0.75rem; }
.why summary { cursor: pointer; font-weight: 600; font-size: 0.9375rem; color: var(--accent); }
.why-list { margin: 0.5rem 0; padding-left: 1.25rem; font-size: 0.9375rem; }
.matched-chunks blockquote, .chunk blockquote, .claim-quote, .rel-excerpts blockquote {
margin: 0.35rem 0 0.75rem;
padding: 0.5rem 0.85rem;
border-left: 3px solid var(--line);
background: var(--surface);
font-size: 0.9375rem;
color: var(--ink-soft);
}
.chunk-loc { font-size: 0.875rem; color: var(--ink-faint); }
.example-list { list-style: none; padding: 0; }
.example-list li { margin: 0.5rem 0; }
/* Answer ---------------------------------------------------------------- */
.answer {
background: var(--paper);
border: 2px solid var(--accent);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.answer-caveat { background: var(--surface-alt); padding: 0.6rem 0.85rem; border-radius: var(--radius); font-size: 0.9375rem; max-width: none; }
.answer-body p { max-width: var(--measure); }
.source-list { padding-left: 1.25rem; }
.source { margin-bottom: 1rem; }
.source-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0 0 0.25rem; max-width: none; }
.source-uncited { opacity: 0.72; }
.ref { font-weight: 700; background: var(--surface-alt); padding: 0.05rem 0.4rem; border-radius: 3px; }
.conflict-list { background: var(--warn-bg); border-left: 4px solid var(--warn); padding: 0.75rem 1rem 0.75rem 2rem; border-radius: var(--radius); }
.conflict-list li { margin: 0.35rem 0; }
.answer-meta { font-size: 0.875rem; }
/* Stats ----------------------------------------------------------------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
list-style: none;
padding: 0;
margin: 0 0 2rem;
}
.stat-grid-compact { margin-bottom: 1rem; }
.stat {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stat-value { font-size: 1.75rem; font-weight: 700; line-height: 1.1; }
.stat-label { font-size: 0.875rem; color: var(--ink-faint); }
.stat-warn { border-color: var(--danger); background: var(--danger-bg); }
.stat-attention { border-color: var(--attention); }
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); gap: 2rem; }
/* Lists and tables ------------------------------------------------------ */
.item-list { list-style: none; padding: 0; margin: 0; }
.item-row { padding: 0.7rem 0; border-bottom: 1px solid var(--line); }
.item-row:last-child { border-bottom: none; }
.item-title { font-weight: 600; text-decoration: none; }
.item-meta { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--ink-faint); max-width: none; }
.table {
width: 100%;
border-collapse: collapse;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: 0.9375rem;
display: block;
overflow-x: auto;
}
.table thead { background: var(--surface-alt); }
.table th, .table td { padding: 0.55rem 0.75rem; text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; }
.table th { font-weight: 600; }
.table tbody tr:last-child td { border-bottom: none; }
.table .num { text-align: right; font-variant-numeric: tabular-nums; }
.table-compact { font-size: 0.875rem; }
.error-cell { max-width: 22rem; color: var(--danger); }
.meta-list { display: grid; grid-template-columns: minmax(10rem, max-content) 1fr; gap: 0.35rem 1rem; margin: 0; }
.meta-list dt { font-weight: 600; color: var(--ink-soft); }
.meta-list dd { margin: 0; overflow-wrap: anywhere; }
/* Pills and tags -------------------------------------------------------- */
.pill {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--surface-alt);
font-size: 0.8125rem;
color: var(--ink-soft);
white-space: nowrap;
}
.pill-ok, .pill-ready { background: var(--ok-bg); color: var(--ok); }
.pill-warn, .pill-partial { background: var(--warn-bg); color: var(--warn); }
.pill-failed { background: var(--danger-bg); color: var(--danger); }
.pill-quiet { opacity: 0.75; }
.pill-job-running { background: #e3edf6; color: var(--accent-dark); }
.pill-job-pending { background: var(--surface-alt); }
.pill-job-succeeded { background: var(--ok-bg); color: var(--ok); }
.pill-job-failed { background: var(--danger-bg); color: var(--danger); }
.pill-prov-model_inferred { background: var(--warn-bg); color: var(--warn); }
.pill-prov-user_confirmed, .pill-prov-explicit { background: var(--ok-bg); color: var(--ok); }
.pill-prov-user_rejected { background: var(--danger-bg); color: var(--danger); }
.pill-review-pending { background: var(--surface-alt); }
.pill-review-confirmed { background: var(--ok-bg); color: var(--ok); }
.pill-review-rejected { background: var(--danger-bg); color: var(--danger); }
.tag {
display: inline-block;
padding: 0.1rem 0.5rem;
margin: 0.1rem 0.2rem 0.1rem 0;
background: var(--surface-alt);
border-radius: 3px;
font-size: 0.875rem;
text-decoration: none;
}
/* Item detail ----------------------------------------------------------- */
.item-detail { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.5rem; }
.detail-head { border-bottom: 1px solid var(--line); padding-bottom: 1rem; margin-bottom: 1rem; }
.detail-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.75rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--line); margin-bottom: 1rem; }
.tabs a { font-size: 0.9375rem; text-decoration: none; }
.content-block { max-height: 32rem; overflow: auto; white-space: pre-wrap; word-break: break-word; }
.chunk-list, .claim-list { list-style: none; padding: 0; }
.chunk, .claim { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
.chunk-head, .claim-meta { display: flex; flex-wrap: wrap; gap: 0.6rem; font-size: 0.875rem; margin: 0 0 0.4rem; max-width: none; }
.claim-statement { font-weight: 600; margin: 0 0 0.35rem; }
.entity-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
.entity-list li { display: flex; gap: 0.4rem; align-items: center; font-size: 0.9375rem; }
/* Relationships --------------------------------------------------------- */
.relationship {
background: var(--paper);
border: 1px solid var(--line);
border-left: 4px solid var(--accent);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.rel-head { margin-bottom: 0.5rem; }
.rel-title { font-weight: 600; margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; max-width: none; }
.rel-arrow { color: var(--ink-faint); font-weight: 400; font-style: italic; }
.rel-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0.35rem 0 0; font-size: 0.9375rem; max-width: none; }
.inference-note {
background: var(--warn-bg);
border-left: 3px solid var(--warn);
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
margin: 0.5rem 0;
max-width: none;
}
.rel-excerpts blockquote cite { display: block; font-size: 0.8125rem; color: var(--ink-faint); font-style: normal; margin-bottom: 0.2rem; }
.evidence { margin: 0.75rem 0; }
.evidence summary { cursor: pointer; font-weight: 600; font-size: 0.9375rem; color: var(--accent); }
.model-opinion { background: var(--surface); padding: 0.75rem 1rem; border-radius: var(--radius); margin-top: 1rem; }
.history-list { font-size: 0.9375rem; padding-left: 1.25rem; }
.note { color: var(--ink-soft); font-style: italic; }
.rel-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--line); }
/* Neighbourhood --------------------------------------------------------- */
.neighbourhood { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.5rem; align-items: start; }
.neighbour-col { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem 1.25rem; }
.neighbour-centre { border-color: var(--accent); border-width: 2px; }
.neighbour-list { list-style: none; padding: 0; margin: 0; }
.neighbour-list li { padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.neighbour-list li:last-child { border-bottom: none; }
.neighbour-meta { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--ink-faint); max-width: none; }
.neighbour-focus .item-title { font-weight: 700; font-size: 1.0625rem; margin: 0 0 0.35rem; }
/* Auth and empty states ------------------------------------------------- */
.auth-panel {
max-width: 26rem;
margin: 3rem auto;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 2rem;
}
.empty {
background: var(--paper);
border: 1px dashed var(--line);
border-radius: var(--radius);
padding: 1.5rem;
text-align: left;
}
.empty h2, .empty h3 { margin-top: 0; }
.error-page { max-width: 40rem; margin: 3rem auto; text-align: left; }
.pagination { display: flex; gap: 1rem; align-items: center; margin: 1.5rem 0; flex-wrap: wrap; }
/* Footer ---------------------------------------------------------------- */
.footer {
border-top: 1px solid var(--line);
background: var(--paper);
padding: 1.25rem;
}
.footer p { max-width: 78rem; margin: 0 auto; font-size: 0.875rem; color: var(--ink-faint); }
/* Responsive ------------------------------------------------------------ */
@media (max-width: 40rem) {
.masthead-inner { flex-direction: column; align-items: stretch; }
.nav-list { gap: 0.25rem 0.75rem; }
.page { padding: 1rem 0.9rem 2rem; }
h1 { font-size: 1.5rem; }
.meta-list { grid-template-columns: 1fr; }
.meta-list dt { margin-top: 0.5rem; }
}
/* Honour a reduced-motion preference. Relata has no decorative animation,
so this only affects smooth scrolling. */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Relata">
<rect width="32" height="32" rx="6" fill="#1f5f8b"/>
<circle cx="10" cy="10" r="3.5" fill="#fff"/>
<circle cx="22" cy="13" r="3" fill="#fff" opacity="0.85"/>
<circle cx="14" cy="23" r="3" fill="#fff" opacity="0.85"/>
<path d="M10 10 L22 13 M10 10 L14 23" stroke="#fff" stroke-width="1.6" opacity="0.7" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+181
View File
@@ -0,0 +1,181 @@
/* Styles for the signed-out public site.
*
* Loaded after app.css so it inherits the same variables, type scale and
* component styles; this file only adds what the marketing pages need. */
.public-masthead {
border-bottom: 1px solid var(--rule, #d8d8d2);
background: var(--surface, #fff);
}
.public-inner {
max-width: 62rem;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.public-nav {
list-style: none;
display: flex;
gap: 1.25rem;
margin: 0;
padding: 0;
}
.public-actions { margin-left: auto; }
.public-page {
max-width: 62rem;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.hero {
padding: 2.5rem 0 1rem;
border-bottom: 1px solid var(--rule, #d8d8d2);
margin-bottom: 2rem;
}
.hero h1 {
font-size: clamp(1.9rem, 4.5vw, 3rem);
line-height: 1.15;
margin: 0 0 1rem;
max-width: 20ch;
}
.lede {
font-size: 1.15rem;
line-height: 1.6;
max-width: 62ch;
color: var(--text-strong, #1c1c1a);
}
.hero-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.75rem;
}
.btn-large { padding: 0.7rem 1.4rem; font-size: 1.02rem; }
.btn-block { display: block; text-align: center; }
.panel {
padding: 2rem 0;
border-bottom: 1px solid var(--rule, #d8d8d2);
}
.panel:last-of-type { border-bottom: 0; }
.panel h2 { margin-top: 0; }
.panel p, .panel li { max-width: 72ch; }
.panel-quiet { background: var(--surface-sunken, #f6f6f3); padding: 2rem 1.5rem; border-radius: 6px; border-bottom: 0; }
.panel-highlight { background: var(--surface-sunken, #f6f6f3); padding: 1.5rem; border-radius: 6px; border: 2px solid var(--accent, #2f5d50); border-bottom: 2px solid var(--accent, #2f5d50); }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}
.card {
border: 1px solid var(--rule, #d8d8d2);
border-radius: 6px;
padding: 1.25rem;
background: var(--surface, #fff);
}
.card h3 { margin-top: 0; font-size: 1.05rem; }
.card-q { font-style: italic; color: var(--text-muted, #55554e); margin: 0.25rem 0 0.75rem; }
.card p { max-width: none; }
.evidence-example {
border: 1px solid var(--rule, #d8d8d2);
border-radius: 6px;
padding: 1.25rem;
margin-top: 1.25rem;
background: var(--surface, #fff);
overflow-x: auto;
}
.evidence-head {
display: flex;
gap: 0.75rem;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.rel-type { font-weight: 700; font-family: var(--mono, ui-monospace, monospace); }
.rel-score { font-variant-numeric: tabular-nums; }
.rel-status { color: var(--text-muted, #55554e); font-size: 0.9rem; }
.evidence-note { font-style: italic; color: var(--text-muted, #55554e); margin-bottom: 0; }
table.weights, table.signals, table.data {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
table.weights th, table.weights td,
table.signals th, table.signals td,
table.data th, table.data td {
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--rule, #d8d8d2);
vertical-align: top;
}
table.weights caption { text-align: left; padding-bottom: 0.5rem; color: var(--text-muted, #55554e); }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* Wide content scrolls inside its own box; the page never scrolls sideways. */
.panel { overflow-x: auto; }
.pipeline { line-height: 1.7; }
.pipeline li { margin-bottom: 0.5rem; }
.why, .wont { line-height: 1.7; }
.wont li { margin-bottom: 0.6rem; }
.public-footer {
border-top: 1px solid var(--rule, #d8d8d2);
background: var(--surface-sunken, #f6f6f3);
margin-top: 2rem;
}
.public-footer p { margin: 0.25rem 0; font-size: 0.92rem; }
.secret {
background: var(--surface-sunken, #f6f6f3);
border: 1px solid var(--rule, #d8d8d2);
border-radius: 4px;
padding: 0.9rem;
overflow-x: auto;
user-select: all;
}
.or-divider {
text-align: center;
position: relative;
color: var(--text-muted, #55554e);
margin: 1.5rem 0;
}
.or-divider::before {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
border-top: 1px solid var(--rule, #d8d8d2);
}
.or-divider span {
position: relative;
background: var(--surface, #fff);
padding: 0 0.75rem;
}
.row-inactive { opacity: 0.6; }
.auth-footer { margin-top: 1.5rem; }
@media (prefers-color-scheme: dark) {
.card, .evidence-example, .public-masthead { background: var(--surface, #1c1c1a); }
.or-divider span { background: var(--surface, #1c1c1a); }
}
+62
View File
@@ -0,0 +1,62 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// assetHandler mirrors how the router mounts the embedded assets: the request
// path has "/static/" stripped, and the file system is expected to be rooted at
// the assets themselves.
func assetHandler() http.Handler {
return http.StripPrefix("/static/", http.FileServerFS(StaticFS()))
}
// Static assets must actually be served. A 404 here does not look like a
// missing file in the browser: the 404 body arrives as text/plain under
// X-Content-Type-Options: nosniff, so the browser refuses it and reports
// corrupted content, which sends you looking for an encoding bug instead.
func TestStaticAssetsAreServed(t *testing.T) {
h := assetHandler()
for _, asset := range []struct{ path, wantType string }{
{"/static/app.css", "text/css"},
{"/static/public.css", "text/css"},
{"/static/app.js", "javascript"},
{"/static/favicon.svg", "image/svg+xml"},
} {
t.Run(asset.path, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, asset.path, nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 — the asset is not reachable at the path "+
"the templates reference", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, asset.wantType) {
t.Fatalf("Content-Type = %q, want something containing %q", ct, asset.wantType)
}
if w.Body.Len() == 0 {
t.Fatal("the asset is empty")
}
})
}
}
// Path traversal must not escape the asset root.
func TestStaticAssetsRefuseTraversal(t *testing.T) {
h := assetHandler()
for _, p := range []string{
"/static/../render.go",
"/static/../../go.mod",
"/static/..%2f..%2fgo.mod",
} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, p, nil))
if w.Code == http.StatusOK {
t.Errorf("%s was served with 200; it must not escape the asset root", p)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
{{define "content"}}
<h1>Add text</h1>
<p class="muted">Paste a note, an email, a transcript, Markdown or a JSON document. The original is stored unchanged.</p>
<form method="post" action="/add" class="stack" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" maxlength="300" value="{{.Form.Title}}"
aria-describedby="title-hint">
<p id="title-hint" class="hint">Optional. A title is generated if you leave this blank.</p>
</div>
<div class="field">
<label for="content">Content</label>
<textarea id="content" name="content" rows="18" required aria-describedby="content-hint">{{.Form.Content}}</textarea>
<p id="content-hint" class="hint">Relata detects the format from the content itself.</p>
</div>
<div class="row">
<div class="field">
<label for="source_type">Source type</label>
<select id="source_type" name="source_type">
{{range sourceTypes}}<option value="{{.}}" {{if eq $.Form.SourceType (printf "%s" .)}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="created_at">Date of the information</label>
<input id="created_at" name="created_at" type="date" value="{{.Form.CreatedAt}}"
aria-describedby="created-hint">
<p id="created-hint" class="hint">When the information itself dates from, if not today.</p>
</div>
</div>
<fieldset class="field">
<legend>Metadata (optional)</legend>
<div class="row">
<div class="field"><label for="mk1">Key</label><input id="mk1" name="metadata_key" type="text" maxlength="64"></div>
<div class="field"><label for="mv1">Value</label><input id="mv1" name="metadata_value" type="text" maxlength="2000"></div>
</div>
<div class="row">
<div class="field"><label for="mk2">Key</label><input id="mk2" name="metadata_key" type="text" maxlength="64"></div>
<div class="field"><label for="mv2">Value</label><input id="mv2" name="metadata_value" type="text" maxlength="2000"></div>
</div>
</fieldset>
<div class="field field-check">
<input id="allow_duplicate" name="allow_duplicate" type="checkbox" value="true">
<label for="allow_duplicate">Store even if identical content already exists</label>
</div>
<button type="submit" class="btn btn-primary">Store and process</button>
</form>
{{end}}
+87
View File
@@ -0,0 +1,87 @@
{{define "content"}}
<h1>Dashboard</h1>
<section aria-labelledby="stats-heading">
<h2 id="stats-heading" class="visually-hidden">Workspace statistics</h2>
<ul class="stat-grid">
<li class="stat"><span class="stat-value">{{.Stats.Items}}</span><span class="stat-label">Items</span></li>
<li class="stat"><span class="stat-value">{{.Stats.Chunks}}</span><span class="stat-label">Chunks</span></li>
<li class="stat"><span class="stat-value">{{.Stats.Entities}}</span><span class="stat-label">Entities</span></li>
<li class="stat"><span class="stat-value">{{.Stats.Relationships}}</span><span class="stat-label">Relationships</span></li>
<li class="stat"><span class="stat-value">{{.Stats.PendingJobs}}</span><span class="stat-label">Pending jobs</span></li>
<li class="stat {{if gt .Stats.FailedJobs 0}}stat-warn{{end}}">
<span class="stat-value">{{.Stats.FailedJobs}}</span><span class="stat-label">Failed jobs</span>
</li>
<li class="stat {{if gt .Stats.AwaitingReview 0}}stat-attention{{end}}">
<span class="stat-value">{{.Stats.AwaitingReview}}</span>
<span class="stat-label">Awaiting relationship review</span>
</li>
</ul>
</section>
<div class="two-col">
<section aria-labelledby="recent-heading">
<h2 id="recent-heading">Recent items</h2>
{{if .RecentItems}}
<ul class="item-list">
{{range .RecentItems}}
<li class="item-row">
<a class="item-title" href="/items/{{.ID}}">{{.Title}}</a>
<p class="item-meta">
<span class="pill pill-{{.Status}}">{{statusLabel .Status}}</span>
<span>{{.SourceType}}</span>
<span>{{ago .IngestedAt}}</span>
{{if .ChunkCount}}<span>{{.ChunkCount}} sections</span>{{end}}
</p>
</li>
{{end}}
</ul>
<p><a href="/items">All items</a></p>
{{else}}
<div class="empty">
<h3>Nothing stored yet</h3>
<p>Relata has no items in this workspace.</p>
<p><a class="btn btn-primary" href="/add">Add a note</a> <a class="btn" href="/upload">Upload a document</a></p>
<p class="muted">You can also load the demonstration dataset with <code>relata seed</code>.</p>
</div>
{{end}}
</section>
<section aria-labelledby="review-heading">
<h2 id="review-heading">Awaiting review</h2>
{{if .PendingRelationships}}
<p class="muted">
These relationships were proposed by analysis. They are <strong>suggestions</strong>,
not established facts, until someone confirms them.
</p>
<ul class="item-list">
{{range .PendingRelationships}}
<li class="item-row">
<a href="/review#rel-{{.Rel.ID}}">{{.SourceTitle}} → {{.TargetTitle}}</a>
<p class="item-meta">
<span class="pill">{{relTypeLabel .Rel.Type}}</span>
<span title="Heuristic strength, not a probability">strength {{score2 .Rel.Score}} ({{strengthLabel .Rel.Score}})</span>
</p>
</li>
{{end}}
</ul>
<p><a href="/review">Review relationships</a></p>
{{else}}
<div class="empty">
<h3>Nothing to review</h3>
<p>No proposed relationships are waiting for a decision.</p>
</div>
{{end}}
</section>
</div>
{{if .ProviderWarnings}}
<section aria-labelledby="providers-heading" class="callout callout-warn">
<h2 id="providers-heading">Model availability</h2>
<ul>
{{range .ProviderWarnings}}<li>{{.}}</li>{{end}}
</ul>
<p class="muted">Ingestion, keyword search and browsing continue to work without a language model.</p>
</section>
{{end}}
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "content"}}
<h1>Entities</h1>
<p class="muted">
People, organisations, places, products, events, dates and concepts extracted from stored items.
Entities are matched only on an exact normalised name — similar names are never merged automatically.
</p>
<form class="filter-bar" method="get" action="/entities">
<div class="field">
<label for="q">Name contains</label>
<input id="q" name="q" type="search" value="{{.Filters.Query}}" autocomplete="off">
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" name="type">
<option value="">Any</option>
{{range entityTypes}}<option value="{{.}}" {{if eq $.Filters.Type (printf "%s" .)}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div>
<button type="submit" class="btn">Filter</button>
<a class="btn btn-quiet" href="/entities">Clear</a>
</form>
{{if .Entities}}
<table class="table">
<caption class="visually-hidden">Extracted entities</caption>
<thead>
<tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col">Mentions</th>
<th scope="col">Items</th><th scope="col">Method</th><th scope="col">Evidence</th></tr>
</thead>
<tbody>
{{range .Entities}}
<tr>
<td><a href="/entities/{{.ID}}">{{.CanonicalName}}</a></td>
<td>{{.Type}}</td>
<td class="num">{{.Mentions}}</td>
<td class="num">{{len .ItemIDs}}</td>
<td>{{.Method}}</td>
<td class="num" title="How well attested this entity is. Not a probability.">{{score2 .EvidenceScore}}</td>
</tr>
{{end}}
</tbody>
</table>
<nav class="pagination" aria-label="Entity pages">
{{if .Pagination.PrevURL}}<a class="btn" href="{{.Pagination.PrevURL}}">Previous</a>{{end}}
<span class="muted">Showing {{.Pagination.From}}–{{.Pagination.To}} of {{.Pagination.Total}}</span>
{{if .Pagination.NextURL}}<a class="btn" href="{{.Pagination.NextURL}}">Next</a>{{end}}
</nav>
{{else}}
<div class="empty"><h2>No entities</h2><p>No entities match this filter.</p></div>
{{end}}
{{end}}
+93
View File
@@ -0,0 +1,93 @@
{{define "content"}}
<h1>{{.Entity.CanonicalName}}</h1>
<p class="item-meta">
<span class="pill">{{.Entity.Type}}</span>
<span>{{.Entity.Mentions}} mention{{if ne .Entity.Mentions 1}}s{{end}}</span>
<span>{{len .Entity.ItemIDs}} item{{if ne (len .Entity.ItemIDs) 1}}s{{end}}</span>
<span>extracted by {{.Entity.Method}}</span>
{{if .Entity.ModelVersion}}<span class="muted">{{.Entity.ModelVersion}}</span>{{end}}
</p>
<section aria-labelledby="items-heading">
<h2 id="items-heading">Items referring to this entity</h2>
{{if .Items}}
<ul class="item-list">
{{range .Items}}
<li class="item-row">
<a class="item-title" href="/items/{{.ID}}">{{.Title}}</a>
<p class="item-meta"><span>{{.SourceType}}</span><span>{{date .CreatedAt}}</span></p>
</li>
{{end}}
</ul>
<p><a href="/search?entity_id={{.Entity.ID}}">Search items associated with {{.Entity.CanonicalName}}</a></p>
{{else}}
<p class="muted">No items currently reference this entity.</p>
{{end}}
</section>
<section aria-labelledby="aliases-heading">
<h2 id="aliases-heading">Candidate aliases</h2>
<p class="muted">
Names that look similar to this one. Relata has <strong>not</strong> merged them:
similar names often belong to genuinely different things.
</p>
{{if .Aliases}}
<table class="table">
<caption class="visually-hidden">Candidate aliases</caption>
<thead><tr><th scope="col">Alias</th><th scope="col">Similarity</th><th scope="col">Status</th>
<th scope="col">Reason</th><th scope="col">Decision</th></tr></thead>
<tbody>
{{range .Aliases}}
<tr>
<td>{{.Alias}}</td>
<td class="num">{{score2 .Similarity}}</td>
<td><span class="pill pill-review-{{.Status}}">{{.Status}}</span></td>
<td class="muted">{{.Reason}}</td>
<td>
<div class="rel-actions">
<form method="post" action="/aliases/{{.ID}}/confirm" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-small btn-ok"
{{if eq .Status "confirmed"}}disabled{{end}}>Same thing</button>
</form>
<form method="post" action="/aliases/{{.ID}}/reject" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-small btn-danger"
{{if eq .Status "rejected"}}disabled{{end}}>Different</button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="hint">
Confirming records that these names refer to the same thing. The two entities remain
<strong>separate records</strong>; the confirmation becomes evidence when relationships
are discovered, weighted below an exact name match.
</p>
{{else}}
<p class="muted">No alias candidates have been proposed.</p>
{{end}}
</section>
<section aria-labelledby="refs-heading">
<h2 id="refs-heading">Where it appears</h2>
{{if .Entity.SourceRefs}}
<ul class="claim-list">
{{range .Entity.SourceRefs}}
<li class="claim">
<blockquote class="claim-quote">{{.Quote}}</blockquote>
<p class="claim-meta muted">
<a href="/items/{{.ItemID}}">source item</a>
<span>bytes {{.Start}}–{{.End}}</span>
{{if .Page}}<span>page {{.Page}}</span>{{end}}
</p>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No stored source references.</p>
{{end}}
</section>
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "content"}}
<div class="error-page">
<h1>{{if .ErrorTitle}}{{.ErrorTitle}}{{else}}Something went wrong{{end}}</h1>
<p>{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}The request could not be completed.{{end}}</p>
{{if .RequestID}}<p class="muted">Reference: <code>{{.RequestID}}</code></p>{{end}}
<p><a class="btn" href="/">Back to the dashboard</a></p>
</div>
{{end}}
+236
View File
@@ -0,0 +1,236 @@
{{define "content"}}
<article class="item-detail">
<header class="detail-head">
<h1>{{.Item.Title}}</h1>
<p class="item-meta">
<span class="pill pill-{{.Item.Status}}">{{statusLabel .Item.Status}}</span>
<span>{{.Item.SourceType}}</span>
<span>{{.Item.MIMEType}}</span>
<span>{{bytes .Item.SizeBytes}}</span>
<span>Created {{date .Item.CreatedAt}}</span>
<span>Ingested {{datetime .Item.IngestedAt}}</span>
</p>
<div class="detail-actions">
<a class="btn" href="/items/{{.Item.ID}}/neighbourhood">Neighbourhood</a>
<form method="post" action="/items/{{.Item.ID}}/reprocess" class="inline-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn">Reprocess</button>
</form>
<form method="post" action="/items/{{.Item.ID}}/delete" class="inline-form"
data-confirm="Delete this item and its derived records? The original content will be removed.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</header>
{{if .Item.Error}}
<p class="flash flash-error" role="alert">{{.Item.Error}}</p>
{{end}}
{{if .Item.Warnings}}
<section class="callout callout-warn" aria-labelledby="warn-heading">
<h2 id="warn-heading">Processing warnings</h2>
<ul>
{{range .Item.Warnings}}
<li><strong>{{.Message}}</strong>{{if .Detail}} <span class="muted">({{.Detail}})</span>{{end}}</li>
{{end}}
</ul>
<p class="muted">The original content is preserved unchanged and can be reprocessed.</p>
</section>
{{end}}
{{if .Item.DuplicateOf}}
<p class="flash flash-warn">
This item has identical content to
<a href="/items/{{.Item.DuplicateOf}}">another stored item</a>. It was kept deliberately.
</p>
{{end}}
<nav class="tabs" aria-label="Item sections">
<a href="#content">Content</a>
<a href="#metadata">Metadata</a>
<a href="#chunks">Sections ({{len .Chunks}})</a>
<a href="#entities">Entities ({{len .Entities}})</a>
<a href="#claims">Claims ({{len .Claims}})</a>
<a href="#relationships">Relationships ({{.RelationshipCount}})</a>
<a href="#history">Processing history</a>
</nav>
<section id="content" aria-labelledby="content-heading">
<h2 id="content-heading">Original content</h2>
{{if .Item.OriginalText}}
<pre class="content-block">{{.Item.OriginalText}}</pre>
{{else if .Item.OriginalFilename}}
<p>
Original file: <strong>{{.Item.OriginalFilename}}</strong> ({{bytes .Item.SizeBytes}}).
The uploaded file is stored unchanged and is never executed or rendered as markup.
</p>
<p><a class="btn" href="/items/{{.Item.ID}}/download">Download original</a></p>
{{else}}
<p class="muted">No original content is retained for this item.</p>
{{end}}
{{if .Item.ExtractedText}}
<h3>Extracted text</h3>
<p class="muted">
Produced by {{.Item.ParserVersion}}. This is what was chunked, embedded and searched.
</p>
<pre class="content-block">{{truncate 20000 .Item.ExtractedText}}</pre>
{{end}}
</section>
<section id="metadata" aria-labelledby="metadata-heading">
<h2 id="metadata-heading">Metadata</h2>
<dl class="meta-list">
<dt>Item ID</dt><dd><code>{{.Item.ID}}</code></dd>
<dt>Content hash</dt><dd><code>{{.Item.ContentHash}}</code></dd>
<dt>Parser</dt><dd>{{if .Item.ParserVersion}}{{.Item.ParserVersion}}{{else}}—{{end}}</dd>
<dt>Embedding model</dt><dd>{{if .Item.EmbeddingModel}}{{.Item.EmbeddingModel}}{{else}}—{{end}}</dd>
<dt>Analysis model</dt><dd>{{if .Item.AnalysisModel}}{{.Item.AnalysisModel}}{{else}}not used{{end}}</dd>
<dt>Scoring version</dt><dd>{{if .Item.ScoreVersion}}{{.Item.ScoreVersion}}{{else}}—{{end}}</dd>
{{range $k, $v := .Item.Metadata}}
<dt>{{$k}}</dt><dd>{{$v}}</dd>
{{end}}
</dl>
</section>
<section id="chunks" aria-labelledby="chunks-heading">
<h2 id="chunks-heading">Sections</h2>
{{if .Chunks}}
<ol class="chunk-list">
{{range .Chunks}}
<li class="chunk">
<p class="chunk-head">
Section {{add .Sequence 1}}
{{if .Page}}<span class="muted">page {{.Page}}</span>{{end}}
{{if .Section}}<span class="muted">{{.Section}}</span>{{end}}
<span class="muted">bytes {{.StartOffset}}–{{.EndOffset}}</span>
{{if .Embedded}}<span class="pill pill-ok">embedded</span>{{else}}<span class="pill">not embedded</span>{{end}}
</p>
<blockquote>{{truncate 800 .Text}}</blockquote>
</li>
{{end}}
</ol>
{{else}}
<div class="empty"><p>This item has no sections. It may still be processing, or no text could be extracted.</p></div>
{{end}}
</section>
<section id="entities" aria-labelledby="entities-heading">
<h2 id="entities-heading">Entities</h2>
{{if .Entities}}
<ul class="entity-list">
{{range .Entities}}
<li>
<a href="/entities/{{.ID}}">{{.CanonicalName}}</a>
<span class="pill">{{.Type}}</span>
<span class="muted">{{.Mentions}} mention{{if ne .Mentions 1}}s{{end}}</span>
<span class="muted">{{.Method}}</span>
</li>
{{end}}
</ul>
{{else}}
<div class="empty"><p>No entities were extracted from this item.</p></div>
{{end}}
</section>
<section id="claims" aria-labelledby="claims-heading">
<h2 id="claims-heading">Claims</h2>
<p class="muted">
A claim records that this source <em>says</em> something. It is not treated as fact.
Each one keeps the exact wording it came from.
</p>
{{if .Claims}}
<ul class="claim-list">
{{range .Claims}}
<li class="claim">
<p class="claim-statement">{{.Statement}}</p>
<blockquote class="claim-quote">{{.SourceRef.Quote}}</blockquote>
<p class="claim-meta muted">
<span>{{.Nature}}</span>
<span>{{.Method}}</span>
{{if .Attribution}}<span>attributed to {{.Attribution}}</span>{{end}}
{{if .Subject}}<span>subject: {{.Subject}}</span>{{end}}
<span>bytes {{.SourceRef.Start}}–{{.SourceRef.End}}</span>
</p>
</li>
{{end}}
</ul>
{{else}}
<div class="empty"><p>No claims were extracted from this item.</p></div>
{{end}}
</section>
<section id="relationships" aria-labelledby="rel-heading">
<h2 id="rel-heading">Relationships</h2>
<p class="muted">
Relationship strength is a heuristic score built from the signals listed under each link.
It is not a calibrated probability.
</p>
<h3>Outgoing</h3>
{{if .Outgoing}}
{{range .Outgoing}}{{template "partial_relationship.html" (dict "R" . "CSRFToken" $.CSRFToken "Direction" "outgoing")}}{{end}}
{{else}}<p class="muted">None.</p>{{end}}
<h3>Incoming</h3>
{{if .Incoming}}
{{range .Incoming}}{{template "partial_relationship.html" (dict "R" . "CSRFToken" $.CSRFToken "Direction" "incoming")}}{{end}}
{{else}}<p class="muted">None.</p>{{end}}
<h3>Add a relationship</h3>
<form method="post" action="/relationships/create" class="inline-grid">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="source_id" value="{{.Item.ID}}">
<div class="field">
<label for="target_id">Related item</label>
<select id="target_id" name="target_id" required>
<option value="">Choose an item…</option>
{{range .OtherItems}}<option value="{{.ID}}">{{truncate 80 .Title}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="rel_type">Relationship</label>
<select id="rel_type" name="type" required>
{{range relTypes}}<option value="{{.}}">{{relTypeLabel .}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="rel_note">Note (optional)</label>
<input id="rel_note" name="note" type="text" maxlength="500">
</div>
<button type="submit" class="btn btn-primary">Add relationship</button>
</form>
</section>
<section id="history" aria-labelledby="history-heading">
<h2 id="history-heading">Processing history</h2>
{{if .Runs}}
<table class="table">
<caption class="visually-hidden">Processing runs for this item</caption>
<thead>
<tr><th scope="col">Started</th><th scope="col">Reason</th><th scope="col">Outcome</th>
<th scope="col">Parser</th><th scope="col">Embedding model</th><th scope="col">Scoring</th></tr>
</thead>
<tbody>
{{range .Runs}}
<tr>
<td>{{datetime .StartedAt}}</td>
<td>{{.Reason}}</td>
<td>{{.Outcome}}{{if .Error}} <span class="muted">{{truncate 120 .Error}}</span>{{end}}</td>
<td>{{.ParserVersion}}</td>
<td>{{.EmbeddingModel}}</td>
<td>{{.ScoreVersion}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">Previous runs are retained for audit; reprocessing never erases them.</p>
{{else}}
<p class="muted">No processing runs recorded.</p>
{{end}}
</section>
</article>
{{end}}
+67
View File
@@ -0,0 +1,67 @@
{{define "content"}}
<h1>Items</h1>
<form class="filter-bar" method="get" action="/items">
<div class="field">
<label for="title">Title contains</label>
<input id="title" name="title" type="search" value="{{.Filters.Title}}" autocomplete="off">
</div>
<div class="field">
<label for="source_type">Source type</label>
<select id="source_type" name="source_type">
<option value="">Any</option>
{{range sourceTypes}}<option value="{{.}}" {{if eq $.Filters.SourceType (printf "%s" .)}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">Any</option>
<option value="ready" {{if eq .Filters.Status "ready"}}selected{{end}}>Ready</option>
<option value="partial" {{if eq .Filters.Status "partial"}}selected{{end}}>Ready with warnings</option>
<option value="failed" {{if eq .Filters.Status "failed"}}selected{{end}}>Failed</option>
<option value="pending" {{if eq .Filters.Status "pending"}}selected{{end}}>Waiting</option>
</select>
</div>
<button type="submit" class="btn">Filter</button>
<a class="btn btn-quiet" href="/items">Clear</a>
</form>
{{if .Items}}
<table class="table">
<caption class="visually-hidden">Stored items</caption>
<thead>
<tr>
<th scope="col">Title</th><th scope="col">Type</th><th scope="col">Status</th>
<th scope="col">Sections</th><th scope="col">Entities</th>
<th scope="col">Created</th><th scope="col">Ingested</th>
</tr>
</thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/items/{{.ID}}">{{truncate 90 .Title}}</a></td>
<td>{{.SourceType}}</td>
<td><span class="pill pill-{{.Status}}">{{statusLabel .Status}}</span></td>
<td class="num">{{.ChunkCount}}</td>
<td class="num">{{.EntityCount}}</td>
<td>{{date .CreatedAt}}</td>
<td>{{ago .IngestedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
<nav class="pagination" aria-label="Item pages">
{{if .Pagination.PrevURL}}<a class="btn" href="{{.Pagination.PrevURL}}">Previous</a>{{end}}
<span class="muted">Showing {{.Pagination.From}}–{{.Pagination.To}} of {{.Pagination.Total}}</span>
{{if .Pagination.NextURL}}<a class="btn" href="{{.Pagination.NextURL}}">Next</a>{{end}}
</nav>
{{else}}
<div class="empty">
<h2>No items match</h2>
<p>Nothing here yet, or the filters exclude everything.</p>
<p><a class="btn btn-primary" href="/add">Add a note</a> <a class="btn" href="/upload">Upload a document</a></p>
</div>
{{end}}
{{end}}
+11
View File
@@ -0,0 +1,11 @@
{{define "content"}}
<h1>Processing queue</h1>
<p class="muted">
Every stage of processing is a background job. Jobs are leased, so a worker that stops
unexpectedly returns its work to the queue rather than holding it.
</p>
<div id="jobs-table" hx-get="/jobs/table" hx-trigger="every 5s" hx-swap="innerHTML">
{{template "partial_jobs_table.html" .}}
</div>
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} — Relata{{else}}Relata{{end}}</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
{{if .Principal}}
<header class="masthead">
<div class="masthead-inner">
<a class="brand" href="/">Relata</a>
<nav aria-label="Main">
<ul class="nav-list">
<li><a href="/" {{if eq .Nav "dashboard"}}aria-current="page"{{end}}>Dashboard</a></li>
<li><a href="/search" {{if eq .Nav "search"}}aria-current="page"{{end}}>Search</a></li>
<li><a href="/items" {{if eq .Nav "items"}}aria-current="page"{{end}}>Items</a></li>
<li><a href="/add" {{if eq .Nav "add"}}aria-current="page"{{end}}>Add text</a></li>
<li><a href="/upload" {{if eq .Nav "upload"}}aria-current="page"{{end}}>Upload</a></li>
<li><a href="/review" {{if eq .Nav "review"}}aria-current="page"{{end}}>Review{{if .PendingReview}} <span class="badge" aria-label="{{.PendingReview}} awaiting review">{{.PendingReview}}</span>{{end}}</a></li>
<li><a href="/entities" {{if eq .Nav "entities"}}aria-current="page"{{end}}>Entities</a></li>
<li><a href="/jobs" {{if eq .Nav "jobs"}}aria-current="page"{{end}}>Queue</a></li>
<li><a href="/settings" {{if eq .Nav "settings"}}aria-current="page"{{end}}>Settings</a></li>
</ul>
</nav>
<form class="signout" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<span class="whoami">{{.Principal.Username}}</span>
<button type="submit" class="btn btn-quiet">Sign out</button>
</form>
</div>
</header>
{{end}}
<main id="main" class="page">
{{if .Flash}}<div class="flash flash-{{.Flash.Kind}}" role="status">{{.Flash.Message}}</div>{{end}}
{{if .Error}}<div class="flash flash-error" role="alert">{{.Error}}</div>{{end}}
{{template "content" .}}
</main>
<footer class="footer">
<p>
Relata stores what you give it and proposes relationships between items.
Relationship strengths are <strong>heuristic scores</strong> computed from listed signals —
they are not calibrated probabilities, and an inferred relationship is a suggestion, not a fact.
</p>
</footer>
<script src="/static/vendor/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>{{end}}
+69
View File
@@ -0,0 +1,69 @@
{{define "content"}}
<div class="auth-panel">
<h1>Sign in to Relata</h1>
{{if .OAuthEnabled}}
<p class="muted">Sign in with your organisation account to reach your workspace.</p>
<p>
<a class="btn btn-primary btn-large btn-block" href="/auth/oauth/start{{if .Next}}?next={{.Next}}{{end}}">
Sign in with {{.ProviderName}}
</a>
</p>
{{end}}
{{if and .OAuthEnabled .LocalEnabled}}
<p class="or-divider"><span>or</span></p>
{{end}}
{{if .LocalEnabled}}
<form method="post" action="/login" class="stack" novalidate>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username"
required {{if not .OAuthEnabled}}autofocus{{end}} autocapitalize="none" spellcheck="false"
aria-describedby="username-hint">
<p id="username-hint" class="hint">The username created with <code>relata createadmin</code>.</p>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
{{if .OAuthEnabled}}
<p class="hint">
Local accounts exist to recover from a problem with {{.ProviderName}}. If you have an
organisation account, use the button above — a local account has its own separate workspace.
</p>
{{end}}
{{end}}
{{if not (or .OAuthEnabled .LocalEnabled)}}
<div class="callout">
<h2>No way to sign in is configured</h2>
<p>
Neither federated nor local sign-in is enabled. Set <code>RELATA_OAUTH_ENABLED=true</code>
or <code>RELATA_LOCAL_LOGIN_ENABLED=true</code> and restart.
</p>
</div>
{{end}}
{{if and .FirstRun .LocalEnabled}}
<div class="callout">
<h2>No accounts exist yet</h2>
<p>Create the first administrator from the command line:</p>
<pre><code>relata createadmin --username admin</code></pre>
<p class="muted">You will be prompted for a password. It is never shown or logged.</p>
</div>
{{end}}
<p class="muted auth-footer">
New here? <a href="/">Read what Relata does</a>.
</p>
</div>
{{end}}
+59
View File
@@ -0,0 +1,59 @@
{{define "content"}}
<h1>Neighbourhood of “{{truncate 80 .Item.Title}}”</h1>
<p class="muted">
The items directly connected to this one. Column position shows direction, not importance.
Strength is a heuristic score, not a probability.
</p>
<p><a href="/items/{{.Item.ID}}">← Back to the item</a></p>
<div class="neighbourhood">
<section class="neighbour-col" aria-labelledby="in-heading">
<h2 id="in-heading">Points at this item ({{len .Incoming}})</h2>
{{if .Incoming}}
<ul class="neighbour-list">
{{range .Incoming}}
<li>
<a href="/items/{{.OtherID}}">{{if .OtherTitle}}{{truncate 70 .OtherTitle}}{{else}}(item){{end}}</a>
<p class="neighbour-meta">
<span class="pill">{{relTypeLabel .Rel.Type}}</span>
<span>strength {{score2 .Rel.Score}} ({{strengthLabel .Rel.Score}})</span>
<span class="pill pill-prov-{{.Rel.Provenance}}">{{provenanceLabel .Rel.Provenance}}</span>
</p>
</li>
{{end}}
</ul>
{{else}}<p class="muted">Nothing points at this item.</p>{{end}}
</section>
<section class="neighbour-col neighbour-centre" aria-labelledby="centre-heading">
<h2 id="centre-heading">This item</h2>
<div class="neighbour-focus">
<p class="item-title">{{.Item.Title}}</p>
<p class="item-meta">
<span class="pill pill-{{.Item.Status}}">{{statusLabel .Item.Status}}</span>
<span>{{.Item.SourceType}}</span>
<span>{{date .Item.CreatedAt}}</span>
</p>
<p class="excerpt">{{truncate 300 .Item.ExtractedText}}</p>
</div>
</section>
<section class="neighbour-col" aria-labelledby="out-heading">
<h2 id="out-heading">This item points at ({{len .Outgoing}})</h2>
{{if .Outgoing}}
<ul class="neighbour-list">
{{range .Outgoing}}
<li>
<a href="/items/{{.OtherID}}">{{if .OtherTitle}}{{truncate 70 .OtherTitle}}{{else}}(item){{end}}</a>
<p class="neighbour-meta">
<span class="pill">{{relTypeLabel .Rel.Type}}</span>
<span>strength {{score2 .Rel.Score}} ({{strengthLabel .Rel.Score}})</span>
<span class="pill pill-prov-{{.Rel.Provenance}}">{{provenanceLabel .Rel.Provenance}}</span>
</p>
</li>
{{end}}
</ul>
{{else}}<p class="muted">This item points at nothing.</p>{{end}}
</section>
</div>
{{end}}
@@ -0,0 +1,68 @@
{{define "partial_answer.html"}}
<section class="answer" aria-labelledby="answer-heading">
<h2 id="answer-heading">Answer from stored knowledge</h2>
<p class="answer-caveat">
This answer was generated from the stored items listed below and nothing else.
It is a summary of what those sources say — not a verified statement of fact.
</p>
{{if not .Answer.Sufficient}}
<p class="flash flash-warn" role="status">
The stored material does not fully answer this question. Treat what follows as partial.
</p>
{{end}}
<div class="answer-body">
{{range .AnswerParagraphs}}<p>{{.}}</p>{{end}}
</div>
<h3>Sources used</h3>
{{if .Answer.Sources}}
<ol class="source-list">
{{range .Answer.Sources}}
<li class="source {{if not .Cited}}source-uncited{{end}}">
<p class="source-head">
<span class="ref">{{.Ref}}</span>
<a href="/items/{{.ItemID}}">{{.Title}}</a>
{{if .Date}}<span class="muted">{{.Date}}</span>{{end}}
{{if not .Cited}}<span class="pill pill-quiet">retrieved but not cited</span>{{end}}
</p>
<blockquote>{{truncate 500 .Excerpt}}</blockquote>
</li>
{{end}}
</ol>
{{else}}
<p class="muted">No stored sources were used.</p>
{{end}}
{{if .Answer.Uncertainty}}
<h3>Uncertainty and conflicts</h3>
<ul class="conflict-list">
{{range .Answer.Uncertainty}}<li>{{.}}</li>{{end}}
</ul>
{{end}}
{{if .Answer.InferredRelationshipsUsed}}
<h3>Inferred relationships used in retrieval</h3>
<p class="muted">
These links were proposed by analysis and have not been confirmed by anyone.
They influenced which sources were retrieved; they are not evidence in themselves.
</p>
<ul>
{{range .Answer.InferredRelationshipsUsed}}
<li>
<a href="/items/{{.ItemID}}">{{if .Title}}{{.Title}}{{else}}(item){{end}}</a>
— {{relTypeLabel .Type}}, strength {{score2 .Strength}} ({{strengthLabel .Strength}})
</li>
{{end}}
</ul>
{{end}}
<p class="answer-meta muted">
Generated by {{if .Answer.Model}}{{.Answer.Model}}{{else}}the configured model{{end}}
from {{.Answer.Retrieved}} retrieved item{{if ne .Answer.Retrieved 1}}s{{end}}
in {{.Answer.TookMS}} ms.
</p>
</section>
{{end}}
@@ -0,0 +1,42 @@
{{define "partial_jobs_table.html"}}
<ul class="stat-grid stat-grid-compact">
<li class="stat"><span class="stat-value">{{.Counts.Pending}}</span><span class="stat-label">Pending</span></li>
<li class="stat"><span class="stat-value">{{.Counts.Running}}</span><span class="stat-label">Running</span></li>
<li class="stat"><span class="stat-value">{{.Counts.Succeeded}}</span><span class="stat-label">Succeeded</span></li>
<li class="stat {{if gt .Counts.Failed 0}}stat-warn{{end}}"><span class="stat-value">{{.Counts.Failed}}</span><span class="stat-label">Failed</span></li>
</ul>
{{if .Jobs}}
<table class="table">
<caption class="visually-hidden">Background jobs</caption>
<thead>
<tr>
<th scope="col">Job</th><th scope="col">Item</th><th scope="col">Status</th>
<th scope="col">Attempts</th><th scope="col">Updated</th><th scope="col">Last error</th><th scope="col"></th>
</tr>
</thead>
<tbody>
{{range .Jobs}}
<tr>
<td>{{.Type}}</td>
<td>{{if .ItemID}}<a href="/items/{{.ItemID}}">item</a>{{else}}—{{end}}</td>
<td><span class="pill pill-job-{{.Status}}">{{.Status}}</span></td>
<td class="num">{{.Attempts}} / {{.MaxAttempts}}</td>
<td>{{ago .UpdatedAt}}</td>
<td class="error-cell">{{if .LastError}}{{truncate 160 .LastError}}{{else}}—{{end}}</td>
<td>
{{if eq .Status "failed"}}
<form method="post" action="/jobs/{{.ID}}/retry" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-small">Retry</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty"><h2>The queue is empty</h2><p>No background jobs are recorded for this workspace.</p></div>
{{end}}
{{end}}
@@ -0,0 +1,124 @@
{{define "partial_relationship.html"}}
{{$r := .R}}
<article class="relationship" id="rel-{{$r.Rel.ID}}">
<header class="rel-head">
<p class="rel-title">
<a href="/items/{{$r.SourceID}}">{{if $r.SourceTitle}}{{$r.SourceTitle}}{{else}}(item){{end}}</a>
<span class="rel-arrow" aria-label="{{relTypeLabel $r.Rel.Type}}">{{relTypeLabel $r.Rel.Type}}</span>
<a href="/items/{{$r.TargetID}}">{{if $r.TargetTitle}}{{$r.TargetTitle}}{{else}}(item){{end}}</a>
</p>
<p class="rel-meta">
<span class="metric" title="Heuristic strength computed from the signals below. Not a probability.">
Strength <strong>{{score2 $r.Rel.Score}}</strong> ({{strengthLabel $r.Rel.Score}})
</span>
<span class="pill pill-prov-{{$r.Rel.Provenance}}">{{provenanceLabel $r.Rel.Provenance}}</span>
<span class="pill pill-review-{{$r.Rel.ReviewStatus}}">{{$r.Rel.ReviewStatus}}</span>
<span class="muted">{{$r.Rel.ScoreVersion}}</span>
</p>
</header>
{{if $r.Rel.Inferred}}
<p class="inference-note">
Proposed by analysis. This is a <strong>suggestion, not an established fact</strong>,
until someone confirms it.
</p>
{{end}}
{{if $r.Rel.Evidence.SourceExcerpt}}
<div class="rel-excerpts">
<blockquote><cite>From the first item</cite>{{$r.Rel.Evidence.SourceExcerpt}}</blockquote>
{{if $r.Rel.Evidence.TargetExcerpt}}
<blockquote><cite>From the second item</cite>{{$r.Rel.Evidence.TargetExcerpt}}</blockquote>
{{end}}
</div>
{{end}}
<details class="evidence">
<summary>Evidence — how this strength was calculated</summary>
<table class="table table-compact">
<caption class="visually-hidden">Signals contributing to the relationship strength</caption>
<thead>
<tr><th scope="col">Signal</th><th scope="col">Evidence</th>
<th scope="col">Strength</th><th scope="col">Weight</th><th scope="col">Contribution</th></tr>
</thead>
<tbody>
{{range $r.Rel.Evidence.Signals}}
<tr>
<td>{{.Type}}</td>
<td>{{.Value}}{{if .Detail}}<br><span class="muted">{{.Detail}}</span>{{end}}</td>
<td class="num">{{score2 .Strength}}</td>
<td class="num">{{score2 .Weight}}</td>
<td class="num">{{score2 .Contribution}}</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="4">Total (capped at 1.00)</th>
<td class="num"><strong>{{score2 $r.Rel.Score}}</strong></td>
</tr>
</tfoot>
</table>
{{if $r.Rel.Evidence.SharedEntities}}
<p>Shared entities: {{range $r.Rel.Evidence.SharedEntities}}<span class="tag">{{.}}</span>{{end}}</p>
{{end}}
{{if $r.Rel.Evidence.ModelRationale}}
<div class="model-opinion">
<h4>Model opinion</h4>
<p class="muted">
A language model{{if $r.Rel.Evidence.ModelVersion}} ({{$r.Rel.Evidence.ModelVersion}}){{end}}
described the connection as follows. This is one signal among those above, not the score itself,
and it is not a verified fact.
</p>
<blockquote>{{$r.Rel.Evidence.ModelRationale}}</blockquote>
</div>
{{end}}
{{if $r.Rel.ReviewHistory}}
<h4>Decision history</h4>
<ul class="history-list">
{{range $r.Rel.ReviewHistory}}
<li>
<strong>{{.Action}}</strong> by {{if .ActorName}}{{.ActorName}}{{else}}unknown{{end}}
on {{datetime .At}}
{{if .FromValue}}<span class="muted">({{.FromValue}} → {{.ToValue}})</span>{{end}}
{{if .Note}}<br><span class="note">“{{.Note}}”</span>{{end}}
</li>
{{end}}
</ul>
<p class="muted">
History is append-only. Rejecting a relationship never deletes the original proposal or its evidence.
</p>
{{end}}
</details>
<div class="rel-actions">
<form method="post" action="/relationships/{{$r.Rel.ID}}/confirm" class="inline-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="note" placeholder="Note (optional)" maxlength="500" aria-label="Note explaining the decision">
<button type="submit" class="btn btn-ok"
{{if eq $r.Rel.ReviewStatus "confirmed"}}disabled{{end}}>Confirm</button>
</form>
<form method="post" action="/relationships/{{$r.Rel.ID}}/reject" class="inline-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-danger"
{{if eq $r.Rel.ReviewStatus "rejected"}}disabled{{end}}>Reject</button>
</form>
<form method="post" action="/relationships/{{$r.Rel.ID}}/type" class="inline-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label class="visually-hidden" for="type-{{$r.Rel.ID}}">Change relationship type</label>
<select id="type-{{$r.Rel.ID}}" name="type">
{{range relTypes}}
<option value="{{.}}" {{if eq . $r.Rel.Type}}selected{{end}}>{{relTypeLabel .}}</option>
{{end}}
</select>
<button type="submit" class="btn">Change type</button>
</form>
</div>
</article>
{{end}}
@@ -0,0 +1,117 @@
{{define "partial_search_results.html"}}
<section aria-labelledby="results-heading">
<h2 id="results-heading">
{{if .Results.Results}}{{.Results.Total}} result{{if ne .Results.Total 1}}s{{end}}{{else}}No results{{end}}
<span class="muted">({{.Results.TookMS}} ms)</span>
</h2>
{{range .Results.Notes}}<p class="flash flash-warn" role="status">{{.}}</p>{{end}}
{{if .Results.Results}}
<p class="score-key">
<strong>Query relevance</strong> is how well an item answers this query.
<strong>Similarity</strong> is how close its wording is to yours.
<strong>Relationship strength</strong> is how well evidenced a link between two items is.
These are three different measurements; none of them is a probability.
</p>
<ol class="result-list">
{{range .Results.Results}}
<li class="result">
<h3><a href="/items/{{.Item.ID}}">{{.Item.Title}}</a></h3>
<p class="result-scores">
<span class="metric" title="How well this item answers your query, relative to the best result">
Query relevance <strong>{{score2 .QueryRelevance}}</strong>
</span>
{{if .SemanticSimilarity}}
<span class="metric" title="Cosine similarity of the closest matching section. Similar wording is not proof of a factual relationship.">
Similarity <strong>{{score2 .SemanticSimilarity}}</strong>
</span>
{{end}}
<span class="pill pill-{{.Item.Status}}">{{statusLabel .Item.Status}}</span>
<span class="muted">{{.Item.SourceType}} · {{date .Item.CreatedAt}}</span>
</p>
{{if .ViaRelationship}}
<p class="via-note">
Not a direct match. Reached because this item is
<strong>{{relTypeLabel .ViaRelationship.Type}}</strong>
a matching item — strength {{score2 .ViaRelationship.Strength}}
({{provenanceLabel .ViaRelationship.Provenance}}).
</p>
{{end}}
<p class="excerpt">{{.Excerpt}}</p>
<details class="why">
<summary>Why this matched</summary>
<ul class="why-list">
{{range .WhyMatched}}<li>{{.}}</li>{{end}}
</ul>
{{if .MatchedEntities}}
<p class="matched-entities">
Matching entities:
{{range .MatchedEntities}}<a class="tag" href="/entities/{{.ID}}">{{.Name}}</a>{{end}}
</p>
{{end}}
{{if .MatchedChunks}}
<div class="matched-chunks">
<h4>Matching sections</h4>
<ul>
{{range .MatchedChunks}}
<li>
<span class="chunk-loc">
Section {{add .Sequence 1}}{{if .Page}}, page {{.Page}}{{end}}{{if .Section}} — {{.Section}}{{end}}
{{if .Similarity}}<span class="muted">(similarity {{score2 .Similarity}})</span>{{end}}
</span>
<blockquote>{{truncate 400 .Text}}</blockquote>
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .Related}}
<div class="related">
<h4>Related items</h4>
<ul>
{{range .Related}}
<li>
<a href="/items/{{.ItemID}}">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>
— {{relTypeLabel .Type}},
strength {{score2 .Strength}} ({{strengthLabel .Strength}}),
{{provenanceLabel .Provenance}}
</li>
{{end}}
</ul>
</div>
{{end}}
</details>
</li>
{{end}}
</ol>
{{if .Pagination.HasMore}}
<nav class="pagination" aria-label="Results pages">
{{if .Pagination.PrevURL}}<a class="btn" href="{{.Pagination.PrevURL}}">Previous</a>{{end}}
<span class="muted">Showing {{.Pagination.From}}–{{.Pagination.To}} of {{.Results.Total}}</span>
{{if .Pagination.NextURL}}<a class="btn" href="{{.Pagination.NextURL}}">Next</a>{{end}}
</nav>
{{end}}
{{else}}
<div class="empty">
<h3>Nothing matched</h3>
<p>No stored item matched “{{.Query.Text}}”.</p>
<ul>
<li>Check that items have finished processing on the <a href="/jobs">queue</a> page.</li>
<li>Try fewer or broader words, or clear the filters.</li>
<li>Meaning-based search needs an embedding model; check <a href="/settings">Settings</a>.</li>
</ul>
</div>
{{end}}
</section>
{{end}}
+103
View File
@@ -0,0 +1,103 @@
{{define "content"}}
<h1>The Relata API</h1>
<p class="lede">
Everything the interface does, <code>/api/v1</code> does too. Authenticate with a bearer
token you create for yourself.
</p>
<section class="panel">
<h2>Getting a token</h2>
<p>
Sign in, then go to <strong>Settings → API tokens</strong>. Choose a name, the scopes it
needs, and how long it should live.
</p>
<p class="callout">
The token is shown <strong>once</strong>. Relata stores only a SHA-256 hash of it, so a
database read — or a backup — yields nothing that can authenticate. If you lose it, revoke
it and make another.
</p>
<h3>Scopes</h3>
<table class="weights">
<thead><tr><th>Scope</th><th>Permits</th></tr></thead>
<tbody>
<tr><td><code>query</code></td><td>Search, generate answers, and read stored items, entities and relationships.</td></tr>
<tr><td><code>ingest</code></td><td>Create items, upload files, reprocess and delete.</td></tr>
<tr><td><code>review</code></td><td>Confirm, reject and retype relationships; confirm entity aliases.</td></tr>
</tbody>
</table>
<p class="muted">
A token never carries more than the role of the person who created it. The check is the
intersection of the two, so demoting someone immediately narrows every token they hold
without any of those tokens being touched.
</p>
</section>
<section class="panel">
<h2>Using it</h2>
<pre><code>export RELATA_TOKEN='rlt_v1_...'
# Search
curl -s -H "Authorization: Bearer $RELATA_TOKEN" \
'https://relata.example/api/v1/search?q=delivery+schedule&limit=5'
# Ingest a note
curl -s -X POST -H "Authorization: Bearer $RELATA_TOKEN" \
-H 'Content-Type: application/json' \
https://relata.example/api/v1/items \
-d '{"title":"Meeting note","content":"The revised cost is 51,500.","source_type":"note"}'
# Ask a question of what is stored
curl -s -X POST -H "Authorization: Bearer $RELATA_TOKEN" \
-H 'Content-Type: application/json' \
https://relata.example/api/v1/search/answer \
-d '{"question":"What is the project cost?"}'</code></pre>
<p>
Bearer requests do not need a CSRF token: CSRF exists to stop a third-party page spending
<em>ambient</em> credentials, and an <code>Authorization</code> header is not ambient — a
browser never attaches one on its own.
</p>
</section>
<section class="panel">
<h2>What a token cannot do</h2>
<ul class="wont">
<li><strong>Reach the web interface.</strong> Tokens authenticate <code>/api/v1</code> only.</li>
<li><strong>Manage tokens.</strong> Creating, listing and revoking tokens requires a
signed-in session, so a leaked token cannot mint its own replacements or extend its life.</li>
<li><strong>Outlive its expiry or revocation.</strong> Both are checked on every request
against the stored record. There is no cached grant to wait out.</li>
<li><strong>Exceed your role.</strong> Scopes narrow permission; they never widen it.</li>
</ul>
</section>
<section class="panel">
<h2>Errors</h2>
<p>Every error is a stable code with a safe message and a request ID:</p>
<pre><code>{
"error": {
"code": "insufficient_scope",
"message": "This API token does not carry the scope this request needs.",
"detail": { "required_scope": "ingest" },
"request_id": "b3f1c0d2-…"
}
}</code></pre>
<p class="muted">
Rate limiting is per token. A token making too many requests gets <code>429</code> with
<code>rate_limited</code>; the limit resets on a fixed window.
</p>
</section>
<section class="panel panel-quiet">
<h2>For operators</h2>
<p>
The redirect URI to register with your identity provider is:
</p>
<pre><code>{{.CallbackURL}}</code></pre>
<p class="muted">
The path is fixed at <code>/auth/oauth/callback</code>; only the host varies. The full
OpenAPI specification is in the repository at <code>docs/openapi.yaml</code>.
</p>
</section>
{{end}}
+142
View File
@@ -0,0 +1,142 @@
{{define "content"}}
<h1>How Relata works</h1>
<p class="lede">
Ingest, derive, connect, retrieve — with the reasoning kept and shown at every step.
</p>
<section class="panel">
<h2>The pipeline</h2>
<ol class="pipeline">
<li><strong>Ingest.</strong> The original bytes are stored verbatim and never rewritten.
An exact duplicate is detected and reported rather than silently stored twice.</li>
<li><strong>Parse.</strong> Text, Markdown, JSON, PDF and DOCX become extracted text plus a
structure map. The parser version is recorded on the item.</li>
<li><strong>Chunk.</strong> Structure-aware splitting, so a chunk is a passage rather than
an arbitrary window. Chunk IDs are deterministic and double as vector IDs.</li>
<li><strong>Embed.</strong> Each chunk is embedded locally; the model that produced each
vector is recorded, so a model change is visible rather than silent.</li>
<li><strong>Extract.</strong> Entities and claims, deterministically where the text allows
and with model assistance where it does not — each labelled with which produced it.</li>
<li><strong>Discover.</strong> A bounded candidate set is scored against explicit signals.
There is no all-pairs comparison and no unexplained number anywhere in it.</li>
</ol>
<p class="muted">
Every stage is a queued job with a lease rather than a lock, so a worker that dies has its
work returned to the queue rather than holding it forever.
</p>
</section>
<section class="panel">
<h2>How a relationship gets its score</h2>
<p>
The score is the weighted sum of the signals that fired, capped at 1.00, under a versioned
scoring model. Each signal is stored with its own value, strength and weight, so the total
is arithmetic you can check.
</p>
<table class="weights">
<thead><tr><th>Signal</th><th>Weight</th><th>What it means</th></tr></thead>
<tbody>
{{range .Weights}}
<tr>
<td><code>{{.Signal}}</code></td>
<td class="num">{{printf "%.2f" .Weight}}</td>
<td>{{.Means}}</td>
</tr>
{{end}}
</tbody>
</table>
<h3>Three deliberate judgements</h3>
<ul>
<li><strong>Similarity alone cannot look like evidence.</strong> Unrelated documents from
one corpus routinely sit at 0.5–0.7 cosine, so raw similarity carries almost no
information at the low end. It is rebased against a floor of 0.55 before it counts.</li>
<li><strong>A model cannot manufacture a relationship.</strong> Its weight is below the
storage threshold, so a maximally confident classification still cannot create a link.
It can shape a relationship other evidence already supports.</li>
<li><strong>Human decisions dominate.</strong> Confirmation saturates the score; rejection
drives it to zero. Neither deletes anything — the original proposal, its provenance and
its signals are all retained.</li>
</ul>
<h3>Diminishing returns</h3>
<p>
Repeated signals of one type contribute 1.0, then 0.5, then 0.25, then nothing. Breadth of
evidence beats repetition: a document mentioning one company twelve times should not
outrank one that relates on several independent grounds.
</p>
</section>
<section class="panel">
<h2>Why each search result was returned</h2>
<p>
A query runs through several independent paths — keyword, vector, entity, title, and
expansion through relationships — each producing its own ordering. They are fused by
<em>reciprocal rank</em> rather than by adding scores, because a keyword score and a cosine
distance are not on comparable scales and whichever had the larger range would dominate.
</p>
<p>Every result carries its reasons in plain words:</p>
<ul class="why">
<li>A section of this item is close in meaning to your query (similarity 0.89).</li>
<li>The item's text contains words from your query.</li>
<li>This item refers to Example Ltd (organisation), which your query names.</li>
<li>Not a direct match. Reached because this item <strong>updates</strong> a matching item —
strength 0.68 (suggested by analysis, not yet reviewed).</li>
</ul>
<p class="muted">
Relevance is normalised against the best result in the same response, so it is comparable
within one result set and not across queries. The interface says so rather than leaving you
to assume otherwise.
</p>
</section>
<section class="panel">
<h2>Answers from stored knowledge</h2>
<p>These hold regardless of what the model returns:</p>
<ol>
<li>The model sees only retrieved passages, inside delimited untrusted blocks.</li>
<li>Passages are labelled <code>S1</code>, <code>S2</code>… — item IDs are never shown to
the model, so a fabricated citation cannot resolve to a real record.</li>
<li>An answer claiming sufficiency while citing nothing is downgraded to insufficient.</li>
<li>Contradictions Relata recorded between the retrieved items are surfaced
<strong>independently of the model's own account</strong>, so a model that quietly picked
a side cannot hide the disagreement.</li>
<li>Inferred relationships that influenced retrieval are listed as proposals, not facts.</li>
<li>If a passage tried to instruct the model, that is reported rather than hidden.</li>
<li>A model that returns nothing usable costs you the prose, not the retrieval: the sources
and conflicts still come back, with the reason the answer was rejected.</li>
</ol>
</section>
<section class="panel">
<h2>Your workspace is your own</h2>
<p>
Each person who signs in gets a private workspace. No document, entity or relationship is
shared with anyone else, and the boundary is enforced in every storage query rather than in
a filter that could be forgotten.
</p>
<p class="muted">
The cost is worth stating plainly: <strong>relationship discovery never spans users.</strong>
Two people who upload documents about the same contract get no link between them, because
as far as Relata is concerned they are separate corpora.
</p>
</section>
<section class="panel panel-quiet">
<h2>Known limitations</h2>
<ul class="wont">
<li>Scores are heuristic strengths, not calibrated probabilities. The weights were chosen by
judgement, not fitted to labelled data.</li>
<li>Deterministic entity extraction is narrow: organisations with a legal suffix, people
with an honorific, email addresses and dates. Broader coverage comes from the model stage
and is labelled as such.</li>
<li>Contradiction detection compares figures and dates about the same coarse subject. It
will not catch a contradiction expressed entirely in prose.</li>
<li>No OCR. A scanned PDF is preserved and flagged as having little extractable text.</li>
<li>Answer generation on a CPU-only machine is slow. A small model produces answers that
are often not worth reading, though the safeguards still hold.</li>
<li>Prompt-injection defences bound the damage; they are not a proof. Treat generated
answers as summaries of sources, not as authority.</li>
</ul>
</section>
{{end}}
+133
View File
@@ -0,0 +1,133 @@
{{define "content"}}
<section class="hero">
<h1>Store what you know. Find out how it connects.</h1>
<p class="lede">
Relata stores arbitrary information, discovers <strong>evidence-backed</strong> relationships
between items, and retrieves related knowledge with an explanation of <em>why</em> each
result was returned.
</p>
<p class="hero-actions">
{{if .SignedIn}}
<a class="btn btn-primary btn-large" href="/">Open your workspace</a>
{{else if .OAuthEnabled}}
<a class="btn btn-primary btn-large" href="/auth/oauth/start">Sign in with {{.ProviderName}}</a>
{{else}}
<a class="btn btn-primary btn-large" href="/login">Sign in</a>
{{end}}
<a class="btn btn-large" href="/how-it-works">How it works</a>
</p>
</section>
<section class="panel">
<h2>Three numbers, never mixed up</h2>
<p>
Most systems collapse "these documents read alike", "these documents are connected" and
"this document answers your question" into a single relevance figure. They are three
different claims with three different meanings, and Relata keeps them apart in storage, in
the API and on screen.
</p>
<div class="cards">
<div class="card">
<h3>Semantic similarity</h3>
<p class="card-q">How alike are these two pieces of text?</p>
<p>Cosine distance between embeddings. Nothing more. Two incident reports written by the
same team in the same house style will score highly and describe entirely different
events.</p>
</div>
<div class="card">
<h3>Relationship strength</h3>
<p class="card-q">How well evidenced is a link between two items?</p>
<p>A weighted sum of named signals, each stored with its own value and weight, so the
arithmetic is always inspectable. Not a probability.</p>
</div>
<div class="card">
<h3>Query relevance</h3>
<p class="card-q">How well does this item answer <em>this</em> question?</p>
<p>Several retrieval paths fused by rank. Comparable within one result set, not across
queries — and it says so.</p>
</div>
</div>
</section>
<section class="panel">
<h2>Similarity is not a relationship</h2>
<p>
This is the distinction the whole design exists to preserve. Two documents that share
wording are not thereby connected; two documents that mention the same company are not
thereby about the same thing. Relata records what it actually found:
</p>
<div class="evidence-example">
<div class="evidence-head">
<span class="rel-type">contradicts</span>
<span class="rel-score">strength 0.72</span>
<span class="rel-status">suggested by analysis — not yet reviewed</span>
</div>
<table class="signals">
<thead><tr><th>Signal</th><th>Value</th><th>Strength</th><th>Weight</th></tr></thead>
<tbody>
<tr><td>claim_contradiction</td><td>cost: 42,000 and 51,500</td><td>1.00</td><td>0.22</td></tr>
<tr><td>shared_entity</td><td>Example Ltd</td><td>1.00</td><td>0.25</td></tr>
<tr><td>semantic_similarity</td><td>0.89 cosine</td><td>0.76</td><td>0.30</td></tr>
</tbody>
</table>
<p class="evidence-note">
Both figures are named. Relata does not decide which is correct.
</p>
</div>
</section>
<section class="panel">
<h2>What a language model is allowed to do</h2>
<p>
A model may classify a relationship's type or describe a connection. Its opinion is
<strong>one signal, weighted 0.15</strong> — below the threshold at which a relationship is
stored at all. A model cannot create a connection on its own, however confident it is. That
is a property of the arithmetic, not a promise, and there is a test asserting it.
</p>
<table class="weights">
<caption>Signal weights in <code>relationship-score-v1</code></caption>
<thead><tr><th>Signal</th><th>Weight</th><th>What it means</th></tr></thead>
<tbody>
{{range .Weights}}
<tr>
<td><code>{{.Signal}}</code></td>
<td class="num">{{printf "%.2f" .Weight}}</td>
<td>{{.Means}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel">
<h2>What it will not do</h2>
<ul class="wont">
<li><strong>It will not merge two entities because their names look alike.</strong>
"Example Ltd" and "Example Holdings Ltd" are different companies far more often than they
are typos of each other. Similar names become a candidate alias awaiting a person's
confirmation, and a confirmed alias still does not merge the records.</li>
<li><strong>It will not infer causation from sequence.</strong> Two things happening in
order yields a chronological statement and nothing more.</li>
<li><strong>It will not hide conflicting evidence.</strong> Contradictions between retrieved
documents are surfaced independently of what any model said about them.</li>
<li><strong>It will not present an inference as a fact.</strong> Every discovered
relationship arrives as a proposal, labelled as one, and nothing is auto-confirmed.</li>
<li><strong>It will not rewrite your originals.</strong> The bytes you supplied are stored
verbatim; everything else is derived and reproducible.</li>
</ul>
</section>
<section class="panel panel-quiet">
<h2>Honest about the limits</h2>
<p>
Relationship scores are heuristic strengths, not calibrated probabilities — the weights were
chosen by judgement, not fitted to labelled data. A 0.7 does not mean "70% likely to be
real". Deterministic extraction is deliberately narrow. There is no OCR. Prompt-injection
defences make injection much harder and bound its effect; they are not a proof.
</p>
<p>
<a href="/how-it-works">The full account is on the how-it-works page</a>, including what is
verified and what is merely intended.
</p>
</section>
{{end}}
+49
View File
@@ -0,0 +1,49 @@
{{define "public_layout.html"}}<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}}{{else}}Relata{{end}}</title>
<meta name="description" content="Relata stores arbitrary information, discovers evidence-backed relationships between items, and retrieves related knowledge with an explanation of why each result was returned.">
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/public.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
</head>
<body class="public">
<a class="skip-link" href="#main">Skip to main content</a>
<header class="public-masthead">
<div class="public-inner">
<a class="brand" href="/">Relata</a>
<nav aria-label="Main">
<ul class="public-nav">
<li><a href="/how-it-works">How it works</a></li>
<li><a href="/api">API</a></li>
</ul>
</nav>
<div class="public-actions">
{{if .SignedIn}}
<a class="btn btn-primary" href="/">Open Relata</a>
{{else if .OAuthEnabled}}
<a class="btn btn-primary" href="/auth/oauth/start">Sign in with {{.ProviderName}}</a>
{{else}}
<a class="btn btn-primary" href="/login">Sign in</a>
{{end}}
</div>
</div>
</header>
<main id="main" class="public-page">
{{template "content" .}}
</main>
<footer class="public-footer">
<div class="public-inner">
<p>Relata {{.Version}} — a self-organising semantic document database.</p>
<p class="muted">
Nothing on this page is read from stored data. Sign in to reach your own workspace.
</p>
</div>
</footer>
</body>
</html>{{end}}
+48
View File
@@ -0,0 +1,48 @@
{{define "content"}}
<h1>Relationship review</h1>
<p class="muted">
These relationships were proposed by analysis from the evidence shown under each one.
Nothing here is treated as fact until a person confirms it. Rejecting a relationship keeps
the proposal and its evidence on record — it is never deleted.
</p>
<form class="filter-bar" method="get" action="/review">
<div class="field">
<label for="review_status">Show</label>
<select id="review_status" name="review_status">
<option value="pending" {{if eq .Filters.Status "pending"}}selected{{end}}>Awaiting review</option>
<option value="confirmed" {{if eq .Filters.Status "confirmed"}}selected{{end}}>Confirmed</option>
<option value="rejected" {{if eq .Filters.Status "rejected"}}selected{{end}}>Rejected</option>
<option value="" {{if eq .Filters.Status ""}}selected{{end}}>All</option>
</select>
</div>
<div class="field">
<label for="type">Relationship type</label>
<select id="type" name="type">
<option value="">Any</option>
{{range relTypes}}<option value="{{.}}" {{if eq $.Filters.Type (printf "%s" .)}}selected{{end}}>{{relTypeLabel .}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="min_score">Minimum strength</label>
<input id="min_score" name="min_score" type="number" min="0" max="1" step="0.05" value="{{.Filters.MinScore}}">
</div>
<button type="submit" class="btn">Filter</button>
</form>
{{if .Relationships}}
{{range .Relationships}}{{template "partial_relationship.html" (dict "R" . "CSRFToken" $.CSRFToken "Direction" "outgoing")}}{{end}}
<nav class="pagination" aria-label="Relationship pages">
{{if .Pagination.PrevURL}}<a class="btn" href="{{.Pagination.PrevURL}}">Previous</a>{{end}}
<span class="muted">Showing {{.Pagination.From}}–{{.Pagination.To}} of {{.Pagination.Total}}</span>
{{if .Pagination.NextURL}}<a class="btn" href="{{.Pagination.NextURL}}">Next</a>{{end}}
</nav>
{{else}}
<div class="empty">
<h2>Nothing to review</h2>
<p>No relationships match this filter.</p>
<p class="muted">Relationships are proposed automatically once items finish processing.</p>
</div>
{{end}}
{{end}}
+104
View File
@@ -0,0 +1,104 @@
{{define "content"}}
<h1>Search</h1>
<form class="search-form" method="get" action="/search" role="search">
<div class="field field-wide">
<label for="q">Search stored knowledge</label>
<input id="q" name="q" type="search" value="{{.Query.Text}}"
placeholder="e.g. What information relates to the revised delivery date?"
autocomplete="off" aria-describedby="q-hint">
<p id="q-hint" class="hint">
Natural language works. Relata combines keyword matching, meaning-based similarity,
entity matching and relationships between items.
</p>
</div>
<details class="filters" {{if .HasFilters}}open{{end}}>
<summary>Filters</summary>
<div class="filter-grid">
<div class="field">
<label for="created_from">Created from</label>
<input id="created_from" name="created_from" type="date" value="{{.Filters.CreatedFrom}}">
</div>
<div class="field">
<label for="created_to">Created to</label>
<input id="created_to" name="created_to" type="date" value="{{.Filters.CreatedTo}}">
</div>
<div class="field">
<label for="source_type">Source type</label>
<select id="source_type" name="source_type">
<option value="">Any</option>
{{range sourceTypes}}
<option value="{{.}}" {{if eq $.Filters.SourceType (printf "%s" .)}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="field">
<label for="mime_type">File type</label>
<select id="mime_type" name="mime_type">
<option value="">Any</option>
<option value="text/plain" {{if eq .Filters.MIMEType "text/plain"}}selected{{end}}>Plain text</option>
<option value="text/markdown" {{if eq .Filters.MIMEType "text/markdown"}}selected{{end}}>Markdown</option>
<option value="application/json" {{if eq .Filters.MIMEType "application/json"}}selected{{end}}>JSON</option>
<option value="application/pdf" {{if eq .Filters.MIMEType "application/pdf"}}selected{{end}}>PDF</option>
<option value="application/vnd.openxmlformats-officedocument.wordprocessingml.document" {{if eq .Filters.MIMEType "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}}selected{{end}}>Word document</option>
</select>
</div>
<div class="field">
<label for="entity_id">Entity</label>
<select id="entity_id" name="entity_id">
<option value="">Any</option>
{{range .AllEntities}}
<option value="{{.ID}}" {{if eq $.Filters.EntityID .ID}}selected{{end}}>{{.CanonicalName}} ({{.Type}})</option>
{{end}}
</select>
</div>
<div class="field">
<label for="relationship_type">Relationship type</label>
<select id="relationship_type" name="relationship_type">
<option value="">Any</option>
{{range relTypes}}
<option value="{{.}}" {{if eq $.Filters.RelType (printf "%s" .)}}selected{{end}}>{{relTypeLabel .}}</option>
{{end}}
</select>
</div>
<div class="field">
<label for="min_relationship_strength">Minimum relationship strength</label>
<input id="min_relationship_strength" name="min_relationship_strength" type="number"
min="0" max="1" step="0.05" value="{{.Filters.MinStrength}}">
<p class="hint">A heuristic strength between 0 and 1, not a probability.</p>
</div>
<div class="field field-check">
<input id="confirmed_only" name="confirmed_only" type="checkbox" value="true"
{{if .Filters.ConfirmedOnly}}checked{{end}}>
<label for="confirmed_only">Only follow relationships a person has confirmed</label>
</div>
</div>
</details>
<div class="search-actions">
<button type="submit" class="btn btn-primary">Search</button>
{{if .AnswerEnabled}}
<button type="submit" name="answer" value="true" class="btn">Answer from stored knowledge</button>
{{end}}
<a class="btn btn-quiet" href="/search">Clear</a>
</div>
</form>
{{if .Answer}}{{template "partial_answer.html" .}}{{end}}
{{if .Searched}}
{{template "partial_search_results.html" .}}
{{else}}
<div class="empty">
<h2>Try a question</h2>
<ul class="example-list">
<li><a href="/search?q=What+information+relates+to+the+revised+delivery+date%3F">What information relates to the revised delivery date?</a></li>
<li><a href="/search?q=Which+sources+disagree+about+the+project+cost%3F">Which sources disagree about the project cost?</a></li>
<li><a href="/search?q=Show+information+associated+with+Example+Ltd">Show information associated with Example Ltd</a></li>
<li><a href="/search?q=What+replaced+the+original+policy%3F">What replaced the original policy?</a></li>
<li><a href="/search?q=Find+items+similar+in+meaning+but+not+actually+about+the+same+event">Find items similar in meaning but not actually about the same event</a></li>
</ul>
</div>
{{end}}
{{end}}
+148
View File
@@ -0,0 +1,148 @@
{{define "content"}}
<h1>System settings</h1>
<p class="muted">
Configuration is read from environment variables at startup. This page shows the values in
effect; secrets are never displayed. Change them in <code>.env</code> and restart Relata.
</p>
<section aria-labelledby="models-heading">
<h2 id="models-heading">Model status</h2>
<table class="table">
<caption class="visually-hidden">AI provider status</caption>
<thead><tr><th scope="col">Provider</th><th scope="col">Model</th><th scope="col">Endpoint</th><th scope="col">Status</th></tr></thead>
<tbody>
{{range .Providers}}
<tr>
<td>{{.Name}}</td>
<td>{{.Model}}</td>
<td><code>{{.Endpoint}}</code></td>
<td>
{{if .Available}}<span class="pill pill-ok">available</span>
{{else}}<span class="pill pill-warn">unavailable</span>{{end}}
{{if .Detail}}<br><span class="muted">{{.Detail}}</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Relata works without a language model: ingestion, chunking, embedding, deterministic entity and
claim extraction, relationship scoring, keyword search and browsing all continue.
Only model-assisted extraction, relationship classification and generated answers require one.
</p>
</section>
<section aria-labelledby="versions-heading">
<h2 id="versions-heading">Versions in use</h2>
<dl class="meta-list">
<dt>Relata</dt><dd>{{.Version}}</dd>
<dt>Parser</dt><dd>{{.ParserVersion}}</dd>
<dt>Relationship scoring</dt><dd>{{.ScoreVersion}}</dd>
<dt>Search ranking</dt><dd>{{.RankVersion}}</dd>
<dt>Embedding dimensions</dt><dd>{{.EmbeddingDims}}</dd>
</dl>
{{if .ModelVersions}}
<h3>Models that produced stored data</h3>
<table class="table">
<caption class="visually-hidden">Model versions observed</caption>
<thead><tr><th scope="col">Kind</th><th scope="col">Name</th><th scope="col">Dimensions</th><th scope="col">First seen</th><th scope="col">Last seen</th></tr></thead>
<tbody>
{{range .ModelVersions}}
<tr>
<td>{{.Kind}}</td><td>{{.Name}}</td>
<td class="num">{{if .Dimensions}}{{.Dimensions}}{{else}}—{{end}}</td>
<td>{{date .FirstSeenAt}}</td><td>{{date .LastSeenAt}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
If the configured embedding model differs from the one that produced stored vectors,
re-embed the workspace so that similarity comparisons remain meaningful.
</p>
{{end}}
</section>
<section aria-labelledby="limits-heading">
<h2 id="limits-heading">Limits in effect</h2>
<dl class="meta-list">
<dt>Maximum upload size</dt><dd>{{.Limits.MaxUpload}}</dd>
<dt>Maximum extracted text</dt><dd>{{.Limits.MaxText}}</dd>
<dt>Maximum JSON nesting depth</dt><dd>{{.Limits.MaxJSONDepth}}</dd>
<dt>Maximum sections per item</dt><dd>{{.Limits.MaxChunks}}</dd>
<dt>Section size ceiling</dt><dd>{{.Limits.ChunkMax}} characters</dd>
<dt>Background workers</dt><dd>{{.Limits.Workers}}</dd>
<dt>Concurrent AI calls</dt><dd>{{.Limits.AIConcurrency}}</dd>
<dt>Concurrent ingestion</dt><dd>{{.Limits.ConcurrentIngest}}</dd>
<dt>Relationship candidates per item</dt><dd>{{.Limits.RelCandidates}}</dd>
<dt>Relationship storage threshold</dt><dd>{{.Limits.RelStoreScore}}</dd>
<dt>Near-duplicate similarity</dt><dd>{{.Limits.NearDuplicate}}</dd>
<dt>Session idle timeout</dt><dd>{{.Limits.SessionIdle}}</dd>
</dl>
</section>
<section aria-labelledby="storage-heading">
<h2 id="storage-heading">Storage</h2>
<dl class="meta-list">
<dt>MongoDB database</dt><dd><code>{{.Storage.MongoDatabase}}</code></dd>
<dt>Qdrant collection</dt><dd><code>{{.Storage.QdrantCollection}}</code></dd>
<dt>Vectors stored</dt><dd>{{.Storage.VectorCount}}</dd>
<dt>Chunks in MongoDB</dt><dd>{{.Storage.ChunkCount}}</dd>
<dt>Upload directory</dt><dd><code>{{.Storage.UploadDir}}</code></dd>
</dl>
{{if .Storage.Mismatch}}
<p class="flash flash-warn">
The number of stored vectors does not match the number of chunks. Re-embedding the
workspace will reconcile them.
</p>
{{end}}
{{if .IsAdmin}}
<form method="post" action="/settings/reembed" class="inline-form"
data-confirm="Re-embed every chunk in this workspace? This will queue background work.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn">Re-embed workspace</button>
</form>
<p class="hint">Use this after changing the embedding model.</p>
{{end}}
</section>
<section aria-labelledby="tokens-heading">
<h2 id="tokens-heading">API access</h2>
<p>
Programs authenticate to <code>/api/v1</code> with bearer tokens you create for yourself.
Each token carries explicit scopes and never more authority than your role.
</p>
<p>
<a class="btn" href="/settings/tokens">Manage API tokens</a>
<a class="btn btn-quiet" href="/api">How the API works</a>
</p>
{{if .OAuthEnabled}}
<p class="hint">
You signed in through {{.ProviderName}}. Your workspace is private to you: no item, entity
or relationship is shared with anyone else, which also means discovery never spans users.
</p>
{{end}}
</section>
<section aria-labelledby="scoring-heading">
<h2 id="scoring-heading">How relationship strength is calculated</h2>
<p>
Relata does not ask a language model for a percentage. Each relationship's strength is the sum
of weighted contributions from explicit signals, capped at 1.00. A model's opinion is
<strong>one signal</strong> and cannot on its own push a relationship past the storage threshold.
</p>
<table class="table">
<caption class="visually-hidden">Signal weights under {{.ScoreVersion}}</caption>
<thead><tr><th scope="col">Signal</th><th scope="col">Weight</th></tr></thead>
<tbody>
{{range .Weights}}<tr><td>{{.Name}}</td><td class="num">{{score2 .Weight}}</td></tr>{{end}}
</tbody>
</table>
<p class="muted">
The resulting number is a <strong>heuristic relationship strength</strong> under
<code>{{.ScoreVersion}}</code>. It is not a calibrated probability and should not be read as one.
</p>
</section>
{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{define "content"}}
<h1>Upload a document</h1>
<p class="muted">
Accepted: plain text, Markdown, JSON, PDF and Word (.docx).
Archives and executables are refused. Uploaded files are stored unchanged and are never executed.
</p>
<form method="post" action="/upload" class="stack" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="field">
<label for="file">File</label>
<input id="file" name="file" type="file" required
accept=".txt,.md,.markdown,.json,.pdf,.docx,text/plain,text/markdown,application/json,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
aria-describedby="file-hint">
<p id="file-hint" class="hint">Maximum {{.MaxUploadMB}} MB. The file type is verified from its contents, not its name.</p>
</div>
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" maxlength="300">
<p class="hint">Optional. The filename is used if you leave this blank.</p>
</div>
<div class="row">
<div class="field">
<label for="source_type">Source type</label>
<select id="source_type" name="source_type">
{{range sourceTypes}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
<div class="field">
<label for="created_at">Date of the information</label>
<input id="created_at" name="created_at" type="date">
</div>
</div>
<div class="field field-check">
<input id="allow_duplicate" name="allow_duplicate" type="checkbox" value="true">
<label for="allow_duplicate">Store even if identical content already exists</label>
</div>
<button type="submit" class="btn btn-primary">Upload and process</button>
</form>
{{if .Warning}}
<div class="callout callout-warn">
<h2>Scanned documents</h2>
<p>Relata does not perform OCR in this version. A scanned PDF with no text layer is stored and preserved, but its contents cannot be searched. Relata will tell you when it detects this.</p>
</div>
{{end}}
{{end}}