813 lines
29 KiB
JavaScript
813 lines
29 KiB
JavaScript
|
|
const state = {
|
||
|
|
projects: [],
|
||
|
|
selectedProject: null,
|
||
|
|
projectDetail: null,
|
||
|
|
ontology: null,
|
||
|
|
siteCrawlPoll: null,
|
||
|
|
siteCrawlPageCount: 0,
|
||
|
|
activeSiteCrawlJobId: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
const $ = (id) => document.getElementById(id);
|
||
|
|
|
||
|
|
function csv(value) {
|
||
|
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function api(path, options = {}) {
|
||
|
|
const response = await fetch(path, {
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
...options,
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
let detail = `${response.status} ${response.statusText}`;
|
||
|
|
try {
|
||
|
|
const body = await response.json();
|
||
|
|
detail = body.detail || body.error || detail;
|
||
|
|
} catch {
|
||
|
|
// Keep status text.
|
||
|
|
}
|
||
|
|
throw new Error(detail);
|
||
|
|
}
|
||
|
|
return response.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toast(message) {
|
||
|
|
const node = $("toast");
|
||
|
|
node.textContent = message;
|
||
|
|
node.classList.add("show");
|
||
|
|
window.setTimeout(() => node.classList.remove("show"), 2400);
|
||
|
|
}
|
||
|
|
|
||
|
|
function requestBase() {
|
||
|
|
return {
|
||
|
|
config_path: $("configPath").value.trim(),
|
||
|
|
source_name: $("sourceSelect").value,
|
||
|
|
url: $("crawlUrl").value.trim(),
|
||
|
|
extractor_provider: $("extractorProvider").value,
|
||
|
|
extractor_model: $("extractorModel").value.trim() || null,
|
||
|
|
extractor_base_url: $("extractorBaseUrl").value.trim() || null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadProjects() {
|
||
|
|
state.projects = await api("/projects");
|
||
|
|
if (!state.selectedProject && state.projects.length) {
|
||
|
|
state.selectedProject = state.projects[0].name;
|
||
|
|
}
|
||
|
|
renderProjects();
|
||
|
|
if (state.selectedProject) {
|
||
|
|
await selectProject(state.selectedProject);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderProjects() {
|
||
|
|
const list = $("projectList");
|
||
|
|
list.innerHTML = "";
|
||
|
|
state.projects.forEach((project) => {
|
||
|
|
const button = document.createElement("button");
|
||
|
|
button.className = `project-item ${state.selectedProject === project.name ? "active" : ""}`;
|
||
|
|
button.innerHTML = `<strong>${escapeHtml(project.name)}</strong><span>${escapeHtml(project.domain)}</span>`;
|
||
|
|
button.addEventListener("click", () => selectProject(project.name));
|
||
|
|
list.appendChild(button);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function selectProject(projectName) {
|
||
|
|
state.selectedProject = projectName;
|
||
|
|
state.projectDetail = await api(`/projects/${encodeURIComponent(projectName)}`);
|
||
|
|
state.ontology = await api(`/ontology/${encodeURIComponent(state.projectDetail.domain)}`);
|
||
|
|
renderProjects();
|
||
|
|
renderOverview();
|
||
|
|
renderOntology();
|
||
|
|
await Promise.all([
|
||
|
|
loadOntologyRegistry(),
|
||
|
|
loadEntities(),
|
||
|
|
loadClaims(),
|
||
|
|
loadExtractionLogs(),
|
||
|
|
loadResearchSessions(),
|
||
|
|
loadGraphQuery(),
|
||
|
|
loadTags(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderOverview() {
|
||
|
|
const detail = state.projectDetail;
|
||
|
|
$("metricProject").textContent = detail?.name ?? "-";
|
||
|
|
$("metricDomain").textContent = detail?.domain ?? "-";
|
||
|
|
$("metricSources").textContent = detail?.sources?.length ?? 0;
|
||
|
|
|
||
|
|
const sourceSelect = $("sourceSelect");
|
||
|
|
sourceSelect.innerHTML = "";
|
||
|
|
(detail?.sources ?? []).forEach((source) => {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = source.name;
|
||
|
|
option.textContent = `${source.name} (${source.type})`;
|
||
|
|
sourceSelect.appendChild(option);
|
||
|
|
});
|
||
|
|
$("sourceTable").innerHTML = table(
|
||
|
|
["Name", "Type", "Trust", "Robots", "Rate"],
|
||
|
|
(detail?.sources ?? []).map((source) => [
|
||
|
|
source.name,
|
||
|
|
source.type,
|
||
|
|
source.trust_level,
|
||
|
|
source.respect_robots_txt ? "on" : "off",
|
||
|
|
`${source.rate_limit_per_minute}/min`,
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderOntology() {
|
||
|
|
const entityTypes = state.ontology?.entity_types ?? [];
|
||
|
|
const predicates = state.ontology?.predicates ?? [];
|
||
|
|
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||
|
|
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||
|
|
$("entityTypeFilter").innerHTML = `<option value="">All types</option>${entityTypes
|
||
|
|
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||
|
|
.join("")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadOntologyRegistry() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const [registry, proposals, gaps, triples] = await Promise.all([
|
||
|
|
api(`/projects/${encodeURIComponent(state.selectedProject)}/ontology/registry`),
|
||
|
|
api(`/projects/${encodeURIComponent(state.selectedProject)}/ontology/proposals?limit=50`),
|
||
|
|
api(`/projects/${encodeURIComponent(state.selectedProject)}/knowledge-gaps?limit=50`),
|
||
|
|
api(`/projects/${encodeURIComponent(state.selectedProject)}/ontology/triples?limit=50`),
|
||
|
|
]);
|
||
|
|
$("ontologyRegistryEntityTable").innerHTML = table(
|
||
|
|
["Type", "Domain", "Status", "Confidence"],
|
||
|
|
(registry.entity_types ?? []).map((row) => [
|
||
|
|
row.name,
|
||
|
|
row.domain,
|
||
|
|
row.status,
|
||
|
|
Math.round((row.confidence ?? 0) * 100) + "%",
|
||
|
|
])
|
||
|
|
);
|
||
|
|
$("ontologyRegistryRelationTable").innerHTML = table(
|
||
|
|
["Relation", "Domain", "Subject", "Object", "Status"],
|
||
|
|
(registry.relation_types ?? []).map((row) => [
|
||
|
|
row.name,
|
||
|
|
row.domain,
|
||
|
|
(row.allowed_subject_types ?? []).join(", "),
|
||
|
|
(row.allowed_object_types ?? []).join(", ") || (row.semantic_constraints?.literal_value ? "literal" : ""),
|
||
|
|
row.status,
|
||
|
|
])
|
||
|
|
);
|
||
|
|
$("ontologyProposalTable").innerHTML = table(
|
||
|
|
["Type", "Name", "Status", "Reason"],
|
||
|
|
(proposals ?? []).map((row) => [row.proposal_type, row.name, row.status, escapeHtml(row.reason ?? "")])
|
||
|
|
);
|
||
|
|
$("knowledgeGapTable").innerHTML = table(
|
||
|
|
["Gap", "Target", "Priority", "Description"],
|
||
|
|
(gaps ?? []).map((row) => [
|
||
|
|
row.gap_type,
|
||
|
|
`${row.target_type ?? ""}: ${row.target_name ?? ""}`,
|
||
|
|
Math.round((row.priority ?? 0) * 100) + "%",
|
||
|
|
escapeHtml(row.description ?? ""),
|
||
|
|
])
|
||
|
|
);
|
||
|
|
$("ontologyTripleTable").innerHTML = table(
|
||
|
|
["Subject", "Relation", "Object", "Status", "Support"],
|
||
|
|
(triples ?? []).map((row) => [
|
||
|
|
row.subject ?? "",
|
||
|
|
row.predicate,
|
||
|
|
row.object ?? JSON.stringify(row.object_value ?? ""),
|
||
|
|
row.status,
|
||
|
|
row.support_count,
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createProject() {
|
||
|
|
const configPath = $("configPath").value.trim();
|
||
|
|
if (!configPath) return;
|
||
|
|
const result = await api("/projects", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({ config_path: configPath }),
|
||
|
|
});
|
||
|
|
toast(`Project created: ${result.name}`);
|
||
|
|
state.selectedProject = result.name;
|
||
|
|
await loadProjects();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function initializeCurrentProject() {
|
||
|
|
const configPath = $("configPath").value.trim();
|
||
|
|
if (!configPath) {
|
||
|
|
toast("Config path is required");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!window.confirm("Reset current project crawl data (pages/entities/claims)?")) return;
|
||
|
|
const result = await api("/projects/reset", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
config_path: configPath,
|
||
|
|
project_name: state.selectedProject,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
state.selectedProject = result.name;
|
||
|
|
await loadProjects();
|
||
|
|
if (result.reset) {
|
||
|
|
const removedClaims = result.deleted?.claims ?? 0;
|
||
|
|
const removedEntities = result.deleted?.entities ?? 0;
|
||
|
|
const removedPages = result.deleted?.pages ?? 0;
|
||
|
|
$("crawlResult").textContent = `Reset done: pages ${removedPages}, entities ${removedEntities}, claims ${removedClaims}`;
|
||
|
|
toast(`Reset completed: ${result.name}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
$("crawlResult").textContent = `Project created: ${result.name}`;
|
||
|
|
toast(`Project created: ${result.name}`);
|
||
|
|
}
|
||
|
|
async function crawl() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
$("crawlResult").textContent = "Crawling one URL...";
|
||
|
|
try {
|
||
|
|
const result = await api("/crawl", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify(requestBase()),
|
||
|
|
});
|
||
|
|
$("crawlResult").textContent =
|
||
|
|
`URL done: page ${result.page_id}, ${result.crawl_status}/${result.extraction_status}, ${result.page_type}, raw ${result.raw_text_length}, clean ${result.clean_text_length}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||
|
|
toast("URL crawl completed");
|
||
|
|
await Promise.all([
|
||
|
|
loadOntologyRegistry(),
|
||
|
|
loadEntities(),
|
||
|
|
loadClaims(),
|
||
|
|
loadExtractionLogs(),
|
||
|
|
loadResearchSessions(),
|
||
|
|
loadGraphQuery(),
|
||
|
|
loadTags(),
|
||
|
|
]);
|
||
|
|
} catch (error) {
|
||
|
|
$("crawlResult").textContent = `Crawl failed: ${error.message}`;
|
||
|
|
toast("Crawl failed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function crawlSite() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
stopSiteCrawlPolling();
|
||
|
|
state.siteCrawlPageCount = 0;
|
||
|
|
state.siteCrawlPollErrorCount = 0;
|
||
|
|
$("crawlResult").textContent = "Starting site crawl from seed...";
|
||
|
|
$("discoveredLinks").innerHTML = "";
|
||
|
|
try {
|
||
|
|
const job = await api("/crawl-site", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
...requestBase(),
|
||
|
|
max_depth: Number($("siteMaxDepth").value || 0),
|
||
|
|
max_pages: Number($("siteMaxPages").value || 1),
|
||
|
|
same_domain_only: $("sameDomainOnly").checked,
|
||
|
|
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
$("crawlResult").textContent = `Site crawl queued: job ${job.job_id}`;
|
||
|
|
state.activeSiteCrawlJobId = job.job_id;
|
||
|
|
$("stopSiteCrawlBtn").disabled = false;
|
||
|
|
renderSiteCrawlProgress(job);
|
||
|
|
pollSiteCrawl(job.job_id);
|
||
|
|
toast("Site crawl started");
|
||
|
|
} catch (error) {
|
||
|
|
$("crawlResult").textContent = `Site crawl failed: ${error.message}`;
|
||
|
|
toast("Site crawl failed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function stopSiteCrawl() {
|
||
|
|
const jobId = state.activeSiteCrawlJobId;
|
||
|
|
if (!jobId) return;
|
||
|
|
$("stopSiteCrawlBtn").disabled = true;
|
||
|
|
$("crawlResult").textContent = `Stopping site crawl: job ${jobId}`;
|
||
|
|
try {
|
||
|
|
const job = await api(`/crawl-site/jobs/${jobId}/cancel`, { method: "POST" });
|
||
|
|
renderSiteCrawlProgress(job);
|
||
|
|
toast("Stop requested");
|
||
|
|
} catch (error) {
|
||
|
|
$("stopSiteCrawlBtn").disabled = false;
|
||
|
|
$("crawlResult").textContent = `Stop failed: ${error.message}`;
|
||
|
|
toast("Stop failed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopSiteCrawlPolling() {
|
||
|
|
if (state.siteCrawlPoll) {
|
||
|
|
window.clearTimeout(state.siteCrawlPoll);
|
||
|
|
state.siteCrawlPoll = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pollSiteCrawl(jobId) {
|
||
|
|
let job;
|
||
|
|
try {
|
||
|
|
job = await api(`/crawl-site/jobs/${jobId}`);
|
||
|
|
} catch (error) {
|
||
|
|
const transient = state.siteCrawlPollErrorCount = (state.siteCrawlPollErrorCount ?? 0) + 1;
|
||
|
|
$("crawlResult").textContent = `Site crawl status check failed (retry ${transient}): ${error.message}`;
|
||
|
|
if (transient >= 5) {
|
||
|
|
stopSiteCrawlPolling();
|
||
|
|
toast("Site crawl status failed");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId), 3000);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
state.siteCrawlPollErrorCount = 0;
|
||
|
|
|
||
|
|
const progress = job.progress ?? {};
|
||
|
|
const pageCount = progress.pages?.length ?? 0;
|
||
|
|
renderSiteCrawlProgress(job);
|
||
|
|
if (pageCount !== state.siteCrawlPageCount) {
|
||
|
|
state.siteCrawlPageCount = pageCount;
|
||
|
|
const loaders = [
|
||
|
|
["ontology registry", loadOntologyRegistry],
|
||
|
|
["entities", loadEntities],
|
||
|
|
["claims", loadClaims],
|
||
|
|
["extraction logs", loadExtractionLogs],
|
||
|
|
["research sessions", loadResearchSessions],
|
||
|
|
["graph query", loadGraphQuery],
|
||
|
|
["tags", loadTags],
|
||
|
|
];
|
||
|
|
await Promise.all(loaders.map(async ([label, fn]) => {
|
||
|
|
try {
|
||
|
|
await fn();
|
||
|
|
} catch (error) {
|
||
|
|
console.warn(`poll: ${label} reload failed`, error);
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
if (["completed", "failed", "canceled"].includes(job.status)) {
|
||
|
|
stopSiteCrawlPolling();
|
||
|
|
state.activeSiteCrawlJobId = null;
|
||
|
|
$("stopSiteCrawlBtn").disabled = true;
|
||
|
|
toast(job.status === "completed" ? "Site crawl completed" : `Site crawl ${job.status}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId), 1500);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSiteCrawlProgress(job) {
|
||
|
|
const progress = job.progress ?? {};
|
||
|
|
const pages = progress.pages ?? [];
|
||
|
|
$("crawlResult").textContent =
|
||
|
|
`Site ${job.status}: visited ${progress.visited_count ?? 0}, analyzed ${progress.analyzed_count ?? 0}, skipped ${progress.skipped_count ?? 0}, queued ${progress.queued_count ?? 0}`;
|
||
|
|
$("discoveredLinks").innerHTML = pages.map(renderSitePage).join("");
|
||
|
|
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||
|
|
button.addEventListener("click", () => {
|
||
|
|
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||
|
|
toast("URL copied to input");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSitePage(page) {
|
||
|
|
const diagnostics = `raw ${page.raw_text_length ?? 0}, clean ${page.clean_text_length ?? 0}, removed ${page.removed_noise_zones_count ?? 0}`;
|
||
|
|
const warnings = (page.warnings ?? []).length ? `, warnings: ${(page.warnings ?? []).join("; ")}` : "";
|
||
|
|
return `
|
||
|
|
<button class="discovered-link" data-discovered-url="${escapeHtml(page.url)}">
|
||
|
|
<strong>${escapeHtml(page.status)} · ${escapeHtml(page.page_type)} · depth ${page.depth}</strong>
|
||
|
|
<span>${escapeHtml(page.url)}</span>
|
||
|
|
<span>${escapeHtml(diagnostics)}${escapeHtml(warnings)}</span>
|
||
|
|
<span>claims ${page.claim_count}, entities ${page.entity_count}, links ${page.discovered_count}${page.error ? `, error: ${escapeHtml(page.error)}` : ""}</span>
|
||
|
|
</button>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function discover() {
|
||
|
|
$("crawlResult").textContent = "Discovering links...";
|
||
|
|
$("discoveredLinks").innerHTML = "";
|
||
|
|
try {
|
||
|
|
const result = await api("/discover", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
config_path: $("configPath").value.trim(),
|
||
|
|
source_name: $("sourceSelect").value,
|
||
|
|
url: $("crawlUrl").value.trim(),
|
||
|
|
limit: 30,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (!result.ok) {
|
||
|
|
$("crawlResult").textContent = result.error ?? "Discovery failed";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
$("crawlResult").textContent = `Discovered ${result.links.length} links`;
|
||
|
|
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||
|
|
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||
|
|
button.addEventListener("click", () => {
|
||
|
|
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||
|
|
toast("URL copied to input");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
$("crawlResult").textContent = `Discovery failed: ${error.message}`;
|
||
|
|
toast("Discovery failed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderDiscoveredLink(link) {
|
||
|
|
return `
|
||
|
|
<button class="discovered-link" data-discovered-url="${escapeHtml(link.url)}">
|
||
|
|
<strong>${escapeHtml(link.label)}</strong>
|
||
|
|
<span>${escapeHtml(link.kind)} · ${escapeHtml(link.url)}</span>
|
||
|
|
</button>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateExtractorOptions() {
|
||
|
|
const provider = $("extractorProvider").value;
|
||
|
|
$("extractorOptions").classList.toggle("active", provider !== "rule_based");
|
||
|
|
if (provider === "ollama" && !$("extractorBaseUrl").value.trim()) {
|
||
|
|
$("extractorBaseUrl").placeholder = "http://localhost:11434/api/chat";
|
||
|
|
} else if (provider === "lm_studio" && !$("extractorBaseUrl").value.trim()) {
|
||
|
|
$("extractorBaseUrl").value = "http://localhost:1234/v1";
|
||
|
|
$("extractorBaseUrl").placeholder = "http://localhost:1234/v1";
|
||
|
|
} else {
|
||
|
|
$("extractorBaseUrl").placeholder = "optional provider endpoint";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function testExtractor(options = {}) {
|
||
|
|
const announce = options.announce ?? true;
|
||
|
|
const provider = $("extractorProvider").value;
|
||
|
|
const baseUrl = $("extractorBaseUrl").value.trim();
|
||
|
|
if (announce) {
|
||
|
|
$("crawlResult").textContent = "Testing analyzer...";
|
||
|
|
}
|
||
|
|
const result = await api("/extractors/models", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({ provider, base_url: baseUrl || null }),
|
||
|
|
});
|
||
|
|
if (!result.ok) {
|
||
|
|
if (announce) {
|
||
|
|
$("crawlResult").textContent = `Analyzer failed: ${result.error}`;
|
||
|
|
toast("Analyzer failed");
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const models = result.models ?? [];
|
||
|
|
if (models.length && !$("extractorModel").value.trim()) {
|
||
|
|
$("extractorModel").value = models[0].id;
|
||
|
|
}
|
||
|
|
if (announce) {
|
||
|
|
$("crawlResult").textContent = models.length
|
||
|
|
? `Analyzer connected. Models: ${models.map((model) => model.id).join(", ")}`
|
||
|
|
: "Analyzer connected. No models returned.";
|
||
|
|
toast("Analyzer connected");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function connectDefaultAnalyzer() {
|
||
|
|
if ($("extractorProvider").value !== "lm_studio") return;
|
||
|
|
try {
|
||
|
|
await testExtractor({ announce: false });
|
||
|
|
} catch {
|
||
|
|
// LM Studio may not be running yet; keep the default fields ready.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadEntities() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const type = $("entityTypeFilter").value;
|
||
|
|
const query = type ? `?entity_type=${encodeURIComponent(type)}&limit=100` : "?limit=100";
|
||
|
|
const entities = await api(`/projects/${encodeURIComponent(state.selectedProject)}/entities${query}`);
|
||
|
|
$("metricEntities").textContent = entities.length;
|
||
|
|
$("entityTable").innerHTML = table(
|
||
|
|
["ID", "Type", "Name", "Metadata"],
|
||
|
|
entities.map((entity) => [
|
||
|
|
entity.id,
|
||
|
|
entity.type,
|
||
|
|
entity.name,
|
||
|
|
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function mergeEntities() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const sourceId = Number($("mergeSourceId").value);
|
||
|
|
const targetId = Number($("mergeTargetId").value);
|
||
|
|
if (!sourceId || !targetId || sourceId === targetId) {
|
||
|
|
toast("Check entity IDs");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const result = await api("/entities/merge", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
project_name: state.selectedProject,
|
||
|
|
source_entity_id: sourceId,
|
||
|
|
target_entity_id: targetId,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (!result.ok) {
|
||
|
|
toast(result.error ?? "Merge failed");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
toast("Entity merged");
|
||
|
|
$("mergeSourceId").value = "";
|
||
|
|
$("mergeTargetId").value = "";
|
||
|
|
await Promise.all([
|
||
|
|
loadOntologyRegistry(),
|
||
|
|
loadEntities(),
|
||
|
|
loadClaims(),
|
||
|
|
loadExtractionLogs(),
|
||
|
|
loadResearchSessions(),
|
||
|
|
loadGraphQuery(),
|
||
|
|
loadTags(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadClaims() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const includeCandidates = $("showCandidateClaims")?.checked ? "&include_candidates=true" : "";
|
||
|
|
const claims = await api(`/projects/${encodeURIComponent(state.selectedProject)}/claims?limit=100${includeCandidates}`);
|
||
|
|
$("claimTable").innerHTML = claims.map(renderClaim).join("");
|
||
|
|
document.querySelectorAll("[data-save-claim]").forEach((button) => {
|
||
|
|
button.addEventListener("click", () => updateClaimConfidence(button.dataset.saveClaim));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderClaim(claim) {
|
||
|
|
const object = claim.object ?? JSON.stringify(claim.object_value ?? "");
|
||
|
|
const breakdown = claim.confidence_breakdown
|
||
|
|
? `LLM ${Math.round((claim.confidence_breakdown.llm_confidence ?? 0) * 100)}%, evidence ${Math.round((claim.confidence_breakdown.evidence_confidence ?? 0) * 100)}%, ontology ${Math.round((claim.confidence_breakdown.ontology_confidence ?? 0) * 100)}%, stored ${Math.round((claim.confidence_breakdown.stored_confidence ?? claim.confidence) * 100)}%`
|
||
|
|
: "";
|
||
|
|
return `
|
||
|
|
<article class="claim-card">
|
||
|
|
<div class="claim-main">
|
||
|
|
<strong>${escapeHtml(claim.subject)}</strong>
|
||
|
|
<span class="predicate">${escapeHtml(claim.predicate)}</span>
|
||
|
|
<span>${escapeHtml(object)}</span>
|
||
|
|
<span>${escapeHtml(claim.status ?? "active")}</span>
|
||
|
|
<span class="confidence">${Math.round(claim.confidence * 100)}%</span>
|
||
|
|
</div>
|
||
|
|
<div class="evidence">${escapeHtml(claim.evidence_text ?? "")}</div>
|
||
|
|
<div class="evidence">${escapeHtml(claim.page_type ?? "")} / ${escapeHtml(claim.source_zone ?? "")} / ${escapeHtml(claim.source_selector ?? "")}</div>
|
||
|
|
<div class="evidence">${escapeHtml(claim.validation_status ?? "")} / graph ${escapeHtml(claim.graph_merge_status ?? "")}${claim.graph_merge_reason ? `: ${escapeHtml(claim.graph_merge_reason)}` : ""}</div>
|
||
|
|
<div class="evidence">${claim.review_required ? `review: ${escapeHtml(claim.review_reason ?? "required")}` : ""}</div>
|
||
|
|
<div class="evidence">${escapeHtml(breakdown)}</div>
|
||
|
|
<div class="evidence">${escapeHtml(claim.source)} · ${escapeHtml(claim.page_url ?? "")}</div>
|
||
|
|
<div class="claim-actions">
|
||
|
|
<input id="confidence-${claim.id}" type="number" min="0" max="1" step="0.01" value="${claim.confidence}" aria-label="confidence" />
|
||
|
|
<input id="reason-${claim.id}" value="${escapeHtml(claim.confidence_reason ?? "")}" aria-label="reason" />
|
||
|
|
<button data-save-claim="${claim.id}">Save</button>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function updateClaimConfidence(claimId) {
|
||
|
|
const confidence = Number($(`confidence-${claimId}`).value);
|
||
|
|
const reason = $(`reason-${claimId}`).value.trim();
|
||
|
|
await api(`/claims/${claimId}/confidence`, {
|
||
|
|
method: "PATCH",
|
||
|
|
body: JSON.stringify({ confidence, reason }),
|
||
|
|
});
|
||
|
|
toast("Claim updated");
|
||
|
|
await loadClaims();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadExtractionLogs() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const logs = await api(`/projects/${encodeURIComponent(state.selectedProject)}/extraction-logs?limit=50`);
|
||
|
|
$("extractionLogTable").innerHTML = logs.map(renderExtractionLog).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderExtractionLog(log) {
|
||
|
|
const validation = log.validation ?? {};
|
||
|
|
const context = log.page_context ?? {};
|
||
|
|
const rejected = validation.rejected_claims ?? [];
|
||
|
|
const rejectedPreview = rejected
|
||
|
|
.slice(0, 3)
|
||
|
|
.map((item) => `${item.predicate ?? ""}: ${item.reason ?? ""}`)
|
||
|
|
.join(" | ");
|
||
|
|
return `
|
||
|
|
<article class="claim-card">
|
||
|
|
<div class="claim-main">
|
||
|
|
<strong>${escapeHtml(log.extractor_name)}</strong>
|
||
|
|
<span>${escapeHtml(log.provider)}</span>
|
||
|
|
<span>${escapeHtml(validation.claim_status ?? "")}</span>
|
||
|
|
<span>accepted ${validation.accepted_claim_count ?? 0}</span>
|
||
|
|
<span>rejected ${validation.rejected_claim_count ?? 0}</span>
|
||
|
|
</div>
|
||
|
|
<div class="evidence">${escapeHtml(context.page_type ?? "")} / ${escapeHtml(context.crawl_status ?? "")} / ${escapeHtml(context.extraction_status ?? "")}</div>
|
||
|
|
<div class="evidence">${escapeHtml(log.page_url ?? "")}</div>
|
||
|
|
<div class="evidence">${escapeHtml(rejectedPreview)}</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runResearch() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
$("researchResult").textContent = "Running graph research...";
|
||
|
|
const seedEntityRaw = $("researchSeedEntityId").value.trim();
|
||
|
|
try {
|
||
|
|
const result = await api("/research/run", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
...requestBase(),
|
||
|
|
project_name: state.selectedProject,
|
||
|
|
seed_entity_id: seedEntityRaw ? Number(seedEntityRaw) : null,
|
||
|
|
goal: $("researchGoal").value.trim() || "Semantic ontology exploration",
|
||
|
|
max_depth: Number($("siteMaxDepth").value || 2),
|
||
|
|
max_steps: Number($("researchMaxSteps").value || 8),
|
||
|
|
max_branch: 8,
|
||
|
|
min_relevance: Number($("researchMinRelevance").value || 0.35),
|
||
|
|
same_domain_only: $("sameDomainOnly").checked,
|
||
|
|
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
$("researchResult").textContent =
|
||
|
|
`Research ${result.status}: explored ${result.explored_count}, analyzed ${result.analyzed_count}, queued ${result.queued_count}, skipped ${result.skipped_count}`;
|
||
|
|
toast("Research completed");
|
||
|
|
await Promise.all([
|
||
|
|
loadOntologyRegistry(),
|
||
|
|
loadEntities(),
|
||
|
|
loadClaims(),
|
||
|
|
loadExtractionLogs(),
|
||
|
|
loadResearchSessions(),
|
||
|
|
loadGraphQuery(),
|
||
|
|
loadTags(),
|
||
|
|
]);
|
||
|
|
} catch (error) {
|
||
|
|
$("researchResult").textContent = `Research failed: ${error.message}`;
|
||
|
|
toast("Research failed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadResearchSessions() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const sessions = await api(`/projects/${encodeURIComponent(state.selectedProject)}/research/sessions?limit=20`);
|
||
|
|
$("researchSessions").innerHTML = sessions.map(renderResearchSession).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderResearchSession(session) {
|
||
|
|
const history = session.history ?? [];
|
||
|
|
const latest = history.length ? history[history.length - 1] : null;
|
||
|
|
const memory = session.memory ?? {};
|
||
|
|
const conflicts = memory.conflicts ?? [];
|
||
|
|
return `
|
||
|
|
<article class="claim-card">
|
||
|
|
<div class="claim-main">
|
||
|
|
<strong>${escapeHtml(session.name ?? "Research session")}</strong>
|
||
|
|
<span>${escapeHtml(session.status ?? "")}</span>
|
||
|
|
<span>history ${history.length}</span>
|
||
|
|
<span>queue ${(session.queue ?? []).length}</span>
|
||
|
|
<span>conflicts ${conflicts.length}</span>
|
||
|
|
</div>
|
||
|
|
<div class="evidence">${escapeHtml(session.goal ?? "")}</div>
|
||
|
|
<div class="evidence">seed ${escapeHtml(session.seed ?? "")}</div>
|
||
|
|
<div class="evidence">${escapeHtml(latest?.outcome?.reason ?? latest?.outcome?.status ?? "")}</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadGraphQuery() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const kind = $("graphQueryKind")?.value ?? "trend_summary";
|
||
|
|
const text = $("graphQueryText")?.value.trim() ?? "";
|
||
|
|
let query = `kind=${encodeURIComponent(kind)}`;
|
||
|
|
if (kind === "brand_products" && text) query += `&brand=${encodeURIComponent(text)}`;
|
||
|
|
if (kind === "products_by_tag" && text) query += `&tag=${encodeURIComponent(text)}`;
|
||
|
|
const rows = await api(`/projects/${encodeURIComponent(state.selectedProject)}/graph/query?${query}`);
|
||
|
|
$("graphQueryTable").innerHTML = renderGraphRows(kind, rows);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderGraphRows(kind, rows) {
|
||
|
|
if (kind === "brand_products") {
|
||
|
|
return table(
|
||
|
|
["Product", "Type", "Brand", "Confidence"],
|
||
|
|
rows.map((row) => [row.product, row.product_type, row.brand ?? "", Math.round(row.confidence * 100) + "%"])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (kind === "products_by_tag") {
|
||
|
|
return table(
|
||
|
|
["Product", "Predicate", "Tag", "Confidence"],
|
||
|
|
rows.map((row) => [row.product, row.predicate, row.tag, Math.round(row.confidence * 100) + "%"])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (kind === "relation_summary") {
|
||
|
|
return table(
|
||
|
|
["Predicate", "Status", "Support", "Confidence"],
|
||
|
|
rows.map((row) => [
|
||
|
|
row.predicate,
|
||
|
|
row.status,
|
||
|
|
row.support_count,
|
||
|
|
Math.round((row.max_confidence ?? 0) * 100) + "%",
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (kind === "entity_type_summary") {
|
||
|
|
return table(
|
||
|
|
["Entity Type", "Count"],
|
||
|
|
rows.map((row) => [row.entity_type, row.count])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return table(
|
||
|
|
["Predicate", "Name", "Type", "Support", "Confidence"],
|
||
|
|
rows.map((row) => [
|
||
|
|
row.predicate,
|
||
|
|
row.name,
|
||
|
|
row.entity_type,
|
||
|
|
row.support_count,
|
||
|
|
Math.round(row.max_confidence * 100) + "%",
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadTags() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||
|
|
$("tagTable").innerHTML = table(
|
||
|
|
["Predicate", "Type", "Name", "Support", "Confidence"],
|
||
|
|
tags.map((tag) => [
|
||
|
|
tag.predicate,
|
||
|
|
tag.type,
|
||
|
|
tag.name,
|
||
|
|
tag.support_count,
|
||
|
|
Math.round(tag.max_confidence * 100) + "%",
|
||
|
|
])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function recommend() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const result = await api("/recommend", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
project_name: state.selectedProject,
|
||
|
|
target_entity_type: state.projectDetail?.config?.recommendation?.target_entity_type ?? "Perfume",
|
||
|
|
preferences: {
|
||
|
|
preferred_notes: csv($("preferredNotes").value),
|
||
|
|
avoided_notes: csv($("avoidedNotes").value),
|
||
|
|
preferred_moods: csv($("preferredMoods").value),
|
||
|
|
season_context: $("seasonContext").value.trim() || null,
|
||
|
|
occasion_context: $("occasionContext").value.trim() || null,
|
||
|
|
},
|
||
|
|
limit: 10,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
$("recommendTable").innerHTML = table(
|
||
|
|
["Name", "Type", "Score", "Reasons"],
|
||
|
|
result.map((item) => [item.name, item.entity_type, item.score, item.reasons.join(", ")])
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function chip(value) {
|
||
|
|
return `<span class="chip">${escapeHtml(value)}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function table(headers, rows) {
|
||
|
|
if (!rows.length) {
|
||
|
|
return `<table><tbody><tr><td>No data.</td></tr></tbody></table>`;
|
||
|
|
}
|
||
|
|
return `
|
||
|
|
<table>
|
||
|
|
<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
|
||
|
|
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${String(cell)}</td>`).join("")}</tr>`).join("")}</tbody>
|
||
|
|
</table>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeHtml(value) {
|
||
|
|
return String(value)
|
||
|
|
.replaceAll("&", "&")
|
||
|
|
.replaceAll("<", "<")
|
||
|
|
.replaceAll(">", ">")
|
||
|
|
.replaceAll('"', """)
|
||
|
|
.replaceAll("'", "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||
|
|
tab.addEventListener("click", () => {
|
||
|
|
document.querySelectorAll(".tab").forEach((node) => node.classList.remove("active"));
|
||
|
|
document.querySelectorAll(".tab-panel").forEach((node) => node.classList.remove("active"));
|
||
|
|
tab.classList.add("active");
|
||
|
|
$(tab.dataset.tab).classList.add("active");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
$("refreshBtn").addEventListener("click", loadProjects);
|
||
|
|
$("createProjectBtn").addEventListener("click", createProject);
|
||
|
|
$("initProjectBtn").addEventListener("click", initializeCurrentProject);
|
||
|
|
$("discoverBtn").addEventListener("click", discover);
|
||
|
|
$("crawlBtn").addEventListener("click", crawl);
|
||
|
|
$("siteCrawlBtn").addEventListener("click", crawlSite);
|
||
|
|
$("stopSiteCrawlBtn").addEventListener("click", stopSiteCrawl);
|
||
|
|
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||
|
|
$("testExtractorBtn").addEventListener("click", testExtractor);
|
||
|
|
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||
|
|
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||
|
|
$("loadClaimsBtn").addEventListener("click", loadClaims);
|
||
|
|
$("showCandidateClaims").addEventListener("change", loadClaims);
|
||
|
|
$("loadExtractionLogsBtn").addEventListener("click", loadExtractionLogs);
|
||
|
|
$("runResearchBtn").addEventListener("click", runResearch);
|
||
|
|
$("loadResearchBtn").addEventListener("click", loadResearchSessions);
|
||
|
|
$("loadGraphQueryBtn").addEventListener("click", loadGraphQuery);
|
||
|
|
$("loadTagsBtn").addEventListener("click", loadTags);
|
||
|
|
$("loadOntologyRegistryBtn").addEventListener("click", loadOntologyRegistry);
|
||
|
|
$("recommendBtn").addEventListener("click", recommend);
|
||
|
|
|
||
|
|
updateExtractorOptions();
|
||
|
|
connectDefaultAnalyzer();
|
||
|
|
loadProjects().catch((error) => toast(error.message));
|