146 lines
16 KiB
TypeScript
146 lines
16 KiB
TypeScript
import { useI18n } from "../i18n";
|
|
import { useState } from "react";
|
|
import { Copy } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { apiFetch, apiErrorMessage } from "../api/client";
|
|
import { listEvidence, getEvidence, type EvidencePage, type EvidenceUnit } from "../api/evidence";
|
|
import { useAuthGeneration } from "../auth/authState";
|
|
import { Button } from "../components/ui/button";
|
|
import { MarkdownView } from "../viewers/MarkdownView";
|
|
import { EvidenceSources } from "./EvidenceSourceReview";
|
|
import { AdministrationHeader } from "./administration/AdministrationPage";
|
|
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
|
|
|
const control = "rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-3 focus-visible:ring-ring/50";
|
|
const kinds = ["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"];
|
|
const states: Record<string, string> = { new: "New file", modified: "Modified", active: "Active locally", removed: "Removed file", review_required: "Needs review", legacy: "Conversion required", invalid: "Invalid file" };
|
|
const templates: Record<string, string> = {
|
|
domain: "## Rule\n\nDescribe the approved domain rule.",
|
|
glossary: "## Definition\n\nDefine the concept.\n\n## Synonyms\n\n- Alternative name\n\n## Variants\n",
|
|
enum: '## Column\n\nsales.orders.status\n\n## Values\n\n### "A"\n\nActive',
|
|
example: "## Question\n\nHow many orders?\n\n## Interpretation\n\nCount distinct orders.",
|
|
mapping: "## Concept\n\nOrders\n\n## Tables\n\n- sales.orders\n\n## Columns\n\n- sales.orders.id",
|
|
normalization: "## Input\n\nA\n\n## Output\n\nActive\n\n## Rule\n\nExpand the status abbreviation.",
|
|
formula: "## Concept\n\nTotal amount\n\n## Columns\n\n- sales.lines.amount\n\n## SQL\n\n```sql\nsum(amount)\n```",
|
|
reference: "## URL\n\nhttps://example.org/policy\n\n## Label\n\nPolicy\n\n## Description\n\nDescribe the reference.",
|
|
};
|
|
|
|
function CopyText({ text, label, showText = true }: { text: string; label: string; showText?: boolean }) {
|
|
const { t } = useI18n();
|
|
const [notice, setNotice] = useState("");
|
|
return <div className="flex min-w-0 flex-wrap items-start gap-2">
|
|
{showText && <code className="min-w-0 flex-1 [overflow-wrap:anywhere] text-xs select-text">{text}</code>}
|
|
<Button size="icon" variant="ghost" title={t("Copy {label}", { label })} aria-label={t("Copy {label}", { label })} onClick={async () => {
|
|
try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
|
|
catch { setNotice("Select the text and copy it manually."); }
|
|
}}><Copy aria-hidden="true" /></Button>{notice && <span role="status" className="basis-full text-xs text-muted-foreground">{t(notice)}</span>}
|
|
</div>;
|
|
}
|
|
|
|
export function EvidenceManagementPage({ canManage, initialWorkspaceId }: { canManage: boolean; initialWorkspaceId?: string }) {
|
|
const { t } = useI18n();
|
|
const context = useWorkingContextScope();
|
|
const generation = useAuthGeneration();
|
|
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
|
|
const workspaces = useQuery({ queryKey: ["evidence-workspaces", generation],
|
|
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
|
|
if (!canManage) return <main className="p-6">{t("Evidence administration is not permitted.")}</main>;
|
|
return <main aria-label={t("Evidence management")} className="thot-administration-page">
|
|
<AdministrationHeader title={t("Evidence management")} description={t("Review workspace knowledge and maintain its Markdown files.")} actions={!context &&
|
|
<label className="grid min-w-52 gap-1 text-sm">{t("Workspace")}<select className={control} value={workspace} onChange={event => setWorkspace(event.target.value)}>
|
|
<option value="">{t("Choose a workspace")}</option>
|
|
{workspaces.data?.map(w => <option key={w.id} value={w.id}>{w.displayName ?? w.id}</option>)}
|
|
</select></label>
|
|
} />
|
|
{workspaces.isError && <p role="alert" className="p-6">{t(apiErrorMessage(workspaces.error))}</p>}
|
|
{workspace ? <WorkspaceEvidence key={`${generation}:${workspace}`} workspace={workspace} generation={generation} />
|
|
: <p className="p-8 text-muted-foreground">{t("Choose a workspace to browse its Evidence and locate the files to edit.")}</p>}
|
|
</main>;
|
|
}
|
|
|
|
function WorkspaceEvidence({ workspace, generation }: { workspace: string; generation: number }) {
|
|
const { t } = useI18n();
|
|
const [filters, setFilters] = useState<Record<string, string>>({ q: "", kind: "", purpose: "", status: "", sort: "title", direction: "asc" });
|
|
const [page, setPage] = useState(1);
|
|
const [selected, setSelected] = useState("");
|
|
const list = useQuery({ queryKey: ["evidence", generation, workspace, filters, page], queryFn: () => listEvidence(workspace, { ...filters, page, page_size: 25 }) });
|
|
const detail = useQuery({ queryKey: ["evidence-detail", generation, workspace, selected], queryFn: () => getEvidence(workspace, selected), enabled: Boolean(selected) });
|
|
function filter(key: string, value: string) { setFilters(old => ({ ...old, [key]: value })); setPage(1); }
|
|
return <div className="flex min-h-0 flex-1 flex-col gap-5 p-6">
|
|
{list.data && <Instructions data={list.data} />}
|
|
<EvidenceSources workspace={workspace} sources={list.data?.source_reviews ?? []} />
|
|
<div className="thot-evidence-filters">
|
|
<label className="thot-evidence-search grid gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
|
|
<Select label={t("Kind")} value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
|
|
<Select label={t("Purpose")} value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
|
|
<Select label={t("Status")} value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
|
|
<Button variant="outline" disabled={list.isFetching || detail.isFetching} onClick={() => { void list.refetch(); if (selected) void detail.refetch(); }}>{t("Refresh files")}</Button>
|
|
</div>
|
|
<details><summary className="cursor-pointer text-sm font-medium">{t("More filters and sorting")}</summary>
|
|
<div className="mt-3 flex flex-wrap gap-3">{["concept", "table", "column", "source"].map(key => <label key={key} className="grid gap-1 text-sm capitalize">{t(key)}<input className={control} value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
|
|
<Select label={t("Sort by")} value={filters.sort} values={["title", "id", "kind", "status"]} onChange={v => filter("sort", v)} all={false} />
|
|
<Select label={t("Order")} value={filters.direction} values={["asc", "desc"]} labels={{ asc: t("Ascending"), desc: t("Descending") }} onChange={v => filter("direction", v)} all={false} />
|
|
</div></details>
|
|
{list.isError && <p role="alert">{t(apiErrorMessage(list.error))}</p>}
|
|
{list.isPending && <p role="status">{t("Loading Evidence files…")}</p>}
|
|
{list.data?.errors.map(error => <div key={error.file} role="alert" className="rounded-md border border-destructive/40 p-3 text-sm"><strong>{error.file}</strong><p>{t(error.message)}</p><p>{t("Correct this file before consolidation.")}</p></div>)}
|
|
<div className="thot-administration-split" style={{ margin: 0 }}>
|
|
<section aria-label={t("Evidence list")} className="thot-administration-index p-4">
|
|
<h2 className="thot-administration-eyebrow mb-3">{t("Evidence library")}</h2>
|
|
<p className="mb-2 text-sm text-muted-foreground">{list.data?.total ?? 0} {t("Evidence units")}</p>
|
|
<div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-2 pr-3 font-medium">{t("Evidence")}</th><th className="pr-3 font-medium">{t("Kind")}</th><th className="font-medium">{t("Status")}</th></tr></thead>
|
|
<tbody>{list.data?.items.map(item => <tr key={item.id} className={`border-b ${selected === item.id ? "bg-muted" : ""}`}><td className="py-3 pr-3"><button className="text-left font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setSelected(item.id)}>{item.title}</button><div className="mt-1 break-all text-xs text-muted-foreground">{item.id}</div></td><td className="pr-3 align-top pt-3">{t(item.kind)}</td><td className="align-top pt-3">{t(states[item.status])}</td></tr>)}</tbody>
|
|
</table></div>
|
|
{list.data?.total === 0 && <p className="py-6 text-sm text-muted-foreground">{t("No Evidence matches these filters. Clear the filters or use the file instructions to create a unit.")}</p>}
|
|
<nav aria-label={t("Evidence pages")} className="mt-4 flex items-center gap-3"><Button variant="outline" size="sm" disabled={page === 1 || list.isFetching} onClick={() => setPage(p => p-1)}>{t("Previous")}</Button><span className="text-sm">{t("Page")} {page}</span><Button variant="outline" size="sm" disabled={!list.data || page*25 >= list.data.total || list.isFetching} onClick={() => setPage(p => p+1)}>{t("Next")}</Button></nav>
|
|
</section>
|
|
<section aria-label={t("Evidence detail")} className="thot-administration-detail">
|
|
{detail.isError ? <p role="alert">{t(apiErrorMessage(detail.error))}</p> : selected && detail.isPending ? <p role="status">{t("Loading Evidence…")}</p> : detail.data?.item ? <UnitDetail item={detail.data.item} location={detail.data.location} /> : <p className="py-10 text-sm text-muted-foreground">{t("Select an Evidence unit to read its complete content and provenance.")}</p>}
|
|
</section>
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
function Select({ label, value, values, labels = {}, onChange, all = true }: { label: string; value: string; values: string[]; labels?: Record<string, string>; onChange: (value: string) => void; all?: boolean }) {
|
|
const { t } = useI18n();
|
|
return <label className="grid gap-1 text-sm">{t(label)}<select className={control} value={value} onChange={e => onChange(e.target.value)}>{all && <option value="">{t("All")}</option>}{values.map(v => <option key={v} value={v}>{t(labels[v] ?? v.replaceAll("_", " "))}</option>)}</select></label>;
|
|
}
|
|
|
|
function Instructions({ data }: { data: EvidencePage }) {
|
|
const { t } = useI18n();
|
|
const [kind, setKind] = useState("domain");
|
|
const example = `---\nschema_version: 4\nid: evidence:my-unit\nkind: ${kind}\nlanguage: en\npurposes: [sql_generation]\n---\n\n# My Evidence\n\n${templates[kind]}\n`;
|
|
return <div className="border-b pb-5">
|
|
<p className="mb-2 text-sm font-medium">{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}</p>
|
|
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">{t("Edit files, consolidate and commit")}</summary>
|
|
{data.location.workspace ? <div className="mt-3"><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></div> : <p role="alert" className="mt-3 text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
|
|
<ol className="mt-3 list-decimal space-y-3 pl-5 text-sm">
|
|
<li>{t("Edit the files under")} <code>evidence/curated/<kind>/</code> {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}</li>
|
|
<li>{t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git inspection command")} /></div>)}</li>
|
|
<li>{t("From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add")} <code>--installation /absolute/path/thothii-installation.yaml</code> {t("immediately after")} <code>tht</code>.
|
|
<div className="mt-2"><CopyText text={data.location.command} label={t("consolidation command")} /></div></li>
|
|
<li>{t("After success, review the generated metadata and complete Git commit/push manually. These commands use a POSIX shell on the installation host. A remote host's path is not a file on this browser's computer.")}{data.location.git_commands.slice(2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git publication command")} /></div>)}</li>
|
|
</ol>
|
|
<p className="mt-3 text-sm text-muted-foreground">{t("Source drafts are preserved under evidence/source/. Maintain the refined files under evidence/curated/. New source acquisition and refresh are not part of consolidation.")}</p>
|
|
<details className="mt-4"><summary className="cursor-pointer text-sm font-medium">{t("Create a manual Evidence file")}</summary><div className="mt-3"><Select label={t("Example kind")} value={kind} values={kinds} onChange={setKind} all={false} /><p className="my-2 text-sm">{t("Save as")} <code>evidence/curated/{kind}/my-unit.md</code> {t("with a unique ID. Replace the example with your domain knowledge. The system records manual provenance.")}</p><pre className="overflow-auto rounded-md bg-muted p-3 text-xs">{example}</pre><CopyText text={example} label={t("Markdown example")} showText={false} /></div></details>
|
|
</details>
|
|
</div>;
|
|
}
|
|
|
|
function UnitDetail({ item, location }: { item: EvidenceUnit; location: EvidencePage["location"] }) {
|
|
const { t } = useI18n();
|
|
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
|
|
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
|
|
return <article className="min-w-0 w-full space-y-5 pb-8 [overflow-wrap:anywhere]">
|
|
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header>
|
|
{file && <CopyText text={file} label={t("Evidence file path")} />}
|
|
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
|
|
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
|
|
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} readable /> : Array.isArray(value) ? <ul className="list-inside list-disc space-y-2 text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || t("(empty value)")}</dt><dd><MarkdownView source={text} readable /></dd></div>)}</dl>}</section>)}
|
|
<section className="border-t pt-4"><h3 className="font-medium">{t("Provenance")}</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}</p>
|
|
{source?.source_file && <><p className="mt-2 [overflow-wrap:anywhere] text-xs">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <div key={i} className="mt-4"><MarkdownView source={text} readable /></div>)}</>}
|
|
</section>
|
|
{item.review_items.length > 0 && <section><h3 className="font-medium">{t("Review required")}</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{t(review.message)}</p>)}</section>}
|
|
</article>;
|
|
}
|