Code sync, security fixes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 |
@@ -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); }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user