Code sync, security fixes

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