496 lines
20 KiB
JavaScript
496 lines
20 KiB
JavaScript
|
|
import { api } from "./api.js";
|
||
|
|
import { state } from "./state.js";
|
||
|
|
import { $, escapeHtml, chip, table, toast, csv } from "./utils.js";
|
||
|
|
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||
|
|
import { requestBase } from "./sidebar.js";
|
||
|
|
import { refreshInspector } from "./inspector.js";
|
||
|
|
import { graphTabHtml, mountGraph, loadGraph } from "./graph.js";
|
||
|
|
import { workbenchHtml, mountWorkbench, loadWorkbench } from "./workbench.js";
|
||
|
|
import { pipelineHtml, mountPipeline, loadPipeline, renderPipelineSourceTable } from "./pipeline.js";
|
||
|
|
|
||
|
|
let host = null;
|
||
|
|
|
||
|
|
export function mountWorkspace(target) {
|
||
|
|
host = target;
|
||
|
|
host.innerHTML = workspaceHtml();
|
||
|
|
applyI18n(host);
|
||
|
|
|
||
|
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||
|
|
tab.addEventListener("click", () => {
|
||
|
|
document.querySelectorAll(".tab").forEach((n) => n.classList.remove("active"));
|
||
|
|
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.remove("active"));
|
||
|
|
tab.classList.add("active");
|
||
|
|
document.getElementById(tab.dataset.tab).classList.add("active");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
mountGraph();
|
||
|
|
mountWorkbench();
|
||
|
|
mountPipeline();
|
||
|
|
$("loadOntologyRegistryBtn").addEventListener("click", loadOntologyRegistry);
|
||
|
|
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||
|
|
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||
|
|
$("loadExtractionLogsBtn").addEventListener("click", loadExtractionLogs);
|
||
|
|
$("runResearchBtn").addEventListener("click", runResearch);
|
||
|
|
$("loadResearchBtn").addEventListener("click", loadResearchSessions);
|
||
|
|
$("loadGraphQueryBtn").addEventListener("click", loadGraphQuery);
|
||
|
|
$("loadTagsBtn").addEventListener("click", loadTags);
|
||
|
|
$("recommendBtn").addEventListener("click", recommend);
|
||
|
|
|
||
|
|
onLangChange(() => {
|
||
|
|
applyI18n(host);
|
||
|
|
renderOverview();
|
||
|
|
renderOntology();
|
||
|
|
loadPipeline().catch(() => {});
|
||
|
|
loadOntologyRegistry().catch(() => {});
|
||
|
|
loadEntities().catch(() => {});
|
||
|
|
loadWorkbench().catch(() => {});
|
||
|
|
loadExtractionLogs().catch(() => {});
|
||
|
|
loadResearchSessions().catch(() => {});
|
||
|
|
loadGraphQuery().catch(() => {});
|
||
|
|
loadTags().catch(() => {});
|
||
|
|
loadGraph().catch(() => {});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function workspaceHtml() {
|
||
|
|
return `
|
||
|
|
<nav class="tabs" aria-label="Admin sections">
|
||
|
|
<button class="tab active" data-tab="overview" data-i18n="tabs.overview"></button>
|
||
|
|
<button class="tab" data-tab="graph" data-i18n="tabs.graph"></button>
|
||
|
|
<button class="tab" data-tab="ontology" data-i18n="tabs.ontology"></button>
|
||
|
|
<button class="tab" data-tab="entities" data-i18n="tabs.entities"></button>
|
||
|
|
<button class="tab" data-tab="claims" data-i18n="tabs.claims"></button>
|
||
|
|
<button class="tab" data-tab="debug" data-i18n="tabs.debug"></button>
|
||
|
|
<button class="tab" data-tab="research" data-i18n="tabs.research"></button>
|
||
|
|
<button class="tab" data-tab="tags" data-i18n="tabs.tags"></button>
|
||
|
|
<button class="tab" data-tab="recommend" data-i18n="tabs.recommend"></button>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
${graphTabHtml()}
|
||
|
|
|
||
|
|
${pipelineHtml()}
|
||
|
|
|
||
|
|
<section id="ontology" class="tab-panel">
|
||
|
|
<div class="toolbar">
|
||
|
|
<button id="loadOntologyRegistryBtn" data-i18n="ontology.refresh_registry"></button>
|
||
|
|
</div>
|
||
|
|
<div class="split">
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.configured_entity_types"></h2>
|
||
|
|
<div id="ontologyEntities" class="chips"></div>
|
||
|
|
</div>
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.configured_predicates"></h2>
|
||
|
|
<div id="ontologyPredicates" class="chips"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="split">
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.registry_entity_types"></h2>
|
||
|
|
<div id="ontologyRegistryEntityTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.registry_relation_types"></h2>
|
||
|
|
<div id="ontologyRegistryRelationTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.triples"></h2>
|
||
|
|
<div id="ontologyTripleTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
<div class="split">
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.proposals"></h2>
|
||
|
|
<div id="ontologyProposalTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="ontology.knowledge_gaps"></h2>
|
||
|
|
<div id="knowledgeGapTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section id="entities" class="tab-panel">
|
||
|
|
<div class="toolbar wrap">
|
||
|
|
<select id="entityTypeFilter"></select>
|
||
|
|
<button id="loadEntitiesBtn" data-i18n="entities.load"></button>
|
||
|
|
</div>
|
||
|
|
<div class="merge-bar">
|
||
|
|
<input id="mergeSourceId" data-i18n-placeholder="entities.merge_source_placeholder" data-i18n-aria-label="entities.merge_source_placeholder" />
|
||
|
|
<input id="mergeTargetId" data-i18n-placeholder="entities.merge_target_placeholder" data-i18n-aria-label="entities.merge_target_placeholder" />
|
||
|
|
<button id="mergeEntitiesBtn" data-i18n="entities.merge"></button>
|
||
|
|
</div>
|
||
|
|
<div id="entityTable" class="table"></div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
${workbenchHtml()}
|
||
|
|
|
||
|
|
<section id="debug" class="tab-panel">
|
||
|
|
<div class="toolbar">
|
||
|
|
<button id="loadExtractionLogsBtn" data-i18n="debug.refresh_logs"></button>
|
||
|
|
</div>
|
||
|
|
<div id="extractionLogTable" class="claim-list"></div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section id="research" class="tab-panel">
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="research.heading"></h2>
|
||
|
|
<div class="recommend-grid">
|
||
|
|
<label>
|
||
|
|
<span data-i18n="research.goal"></span>
|
||
|
|
<input id="researchGoal" value="Forment perfume ecosystem exploration" />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span data-i18n="research.seed_entity"></span>
|
||
|
|
<input id="researchSeedEntityId" placeholder="optional entity id" />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span data-i18n="research.max_steps"></span>
|
||
|
|
<input id="researchMaxSteps" type="number" min="1" max="50" value="8" />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span data-i18n="research.min_relevance"></span>
|
||
|
|
<input id="researchMinRelevance" type="number" min="0" max="1" step="0.05" value="0.35" />
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<div class="button-grid">
|
||
|
|
<button id="runResearchBtn" class="primary" data-i18n="research.run"></button>
|
||
|
|
<button id="loadResearchBtn" data-i18n="research.load_sessions"></button>
|
||
|
|
</div>
|
||
|
|
<div id="researchResult" class="mini-log"></div>
|
||
|
|
<div id="researchSessions" class="claim-list"></div>
|
||
|
|
</div>
|
||
|
|
<div class="wide-panel">
|
||
|
|
<h2 data-i18n="research.graph_query"></h2>
|
||
|
|
<div class="toolbar wrap">
|
||
|
|
<select id="graphQueryKind">
|
||
|
|
<option value="trend_summary" data-i18n="research.query.trend_summary"></option>
|
||
|
|
<option value="brand_products" data-i18n="research.query.brand_products"></option>
|
||
|
|
<option value="products_by_tag" data-i18n="research.query.products_by_tag"></option>
|
||
|
|
<option value="relation_summary" data-i18n="research.query.relation_summary"></option>
|
||
|
|
<option value="entity_type_summary" data-i18n="research.query.entity_type_summary"></option>
|
||
|
|
</select>
|
||
|
|
<input id="graphQueryText" data-i18n-placeholder="research.filter_placeholder" />
|
||
|
|
<button id="loadGraphQueryBtn" data-i18n="research.run_query"></button>
|
||
|
|
</div>
|
||
|
|
<div id="graphQueryTable" class="table"></div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section id="tags" class="tab-panel">
|
||
|
|
<div class="toolbar">
|
||
|
|
<button id="loadTagsBtn" data-i18n="tags.load"></button>
|
||
|
|
</div>
|
||
|
|
<div id="tagTable" class="table"></div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section id="recommend" class="tab-panel">
|
||
|
|
<div class="recommend-grid">
|
||
|
|
<label><span data-i18n="recommend.preferred_notes"></span><input id="preferredNotes" value="Bergamot, Musk" /></label>
|
||
|
|
<label><span data-i18n="recommend.avoided_notes"></span><input id="avoidedNotes" value="" /></label>
|
||
|
|
<label><span data-i18n="recommend.preferred_moods"></span><input id="preferredMoods" value="Fresh" /></label>
|
||
|
|
<label><span data-i18n="recommend.season"></span><input id="seasonContext" value="Summer" /></label>
|
||
|
|
<label><span data-i18n="recommend.occasion"></span><input id="occasionContext" value="Daily" /></label>
|
||
|
|
</div>
|
||
|
|
<button id="recommendBtn" class="primary" data-i18n="recommend.test"></button>
|
||
|
|
<div id="recommendTable" class="table"></div>
|
||
|
|
</section>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderOverview() {
|
||
|
|
renderPipelineSourceTable(state.projectDetail);
|
||
|
|
}
|
||
|
|
|
||
|
|
export 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="">${escapeHtml(t("entities.all_types"))}</option>${entityTypes
|
||
|
|
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||
|
|
.join("")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function loadOntologyRegistry() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const proj = encodeURIComponent(state.selectedProject);
|
||
|
|
const [registry, proposals, gaps, triples] = await Promise.all([
|
||
|
|
api(`/projects/${proj}/ontology/registry`),
|
||
|
|
api(`/projects/${proj}/ontology/proposals?limit=50`),
|
||
|
|
api(`/projects/${proj}/knowledge-gaps?limit=50`),
|
||
|
|
api(`/projects/${proj}/ontology/triples?limit=50`),
|
||
|
|
]);
|
||
|
|
$("ontologyRegistryEntityTable").innerHTML = table(
|
||
|
|
[t("table.type"), t("table.domain"), t("table.status"), t("table.confidence")],
|
||
|
|
(registry.entity_types ?? []).map((row) => [
|
||
|
|
row.name, row.domain, row.status, Math.round((row.confidence ?? 0) * 100) + "%",
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
$("ontologyRegistryRelationTable").innerHTML = table(
|
||
|
|
[t("table.relation"), t("table.domain"), t("table.subject"), t("table.object"), t("table.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(
|
||
|
|
[t("table.type"), t("table.name"), t("table.status"), t("table.reason")],
|
||
|
|
(proposals ?? []).map((row) => [row.proposal_type, row.name, row.status, escapeHtml(row.reason ?? "")]),
|
||
|
|
);
|
||
|
|
$("knowledgeGapTable").innerHTML = table(
|
||
|
|
[t("table.gap"), t("table.target"), t("table.priority"), t("table.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(
|
||
|
|
[t("table.subject"), t("table.relation"), t("table.object"), t("table.status"), t("table.support")],
|
||
|
|
(triples ?? []).map((row) => [
|
||
|
|
row.subject ?? "",
|
||
|
|
row.predicate,
|
||
|
|
row.object ?? JSON.stringify(row.object_value ?? ""),
|
||
|
|
row.status,
|
||
|
|
row.support_count,
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
let lastEntities = [];
|
||
|
|
|
||
|
|
export 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}`);
|
||
|
|
lastEntities = entities;
|
||
|
|
$("metricEntities").textContent = entities.length;
|
||
|
|
$("entityTable").innerHTML = table(
|
||
|
|
[t("table.id"), t("table.type"), t("table.name"), t("table.metadata")],
|
||
|
|
entities.map((entity) => [
|
||
|
|
`<button class="row-link" data-entity-id="${entity.id}">${escapeHtml(entity.id)}</button>`,
|
||
|
|
entity.type,
|
||
|
|
escapeHtml(entity.name),
|
||
|
|
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
$("entityTable").querySelectorAll("[data-entity-id]").forEach((btn) => {
|
||
|
|
btn.addEventListener("click", () => {
|
||
|
|
const id = Number(btn.dataset.entityId);
|
||
|
|
const e = lastEntities.find((x) => x.id === id);
|
||
|
|
if (!e) return;
|
||
|
|
state.selection = { kind: "entity", data: e };
|
||
|
|
refreshInspector();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function mergeEntities() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const sourceId = Number($("mergeSourceId").value);
|
||
|
|
const targetId = Number($("mergeTargetId").value);
|
||
|
|
if (!sourceId || !targetId || sourceId === targetId) {
|
||
|
|
toast(t("toast.merge_check"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
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 ?? t("toast.merge_check"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
toast(t("toast.entity_merged"));
|
||
|
|
$("mergeSourceId").value = "";
|
||
|
|
$("mergeTargetId").value = "";
|
||
|
|
const event = new CustomEvent("workspace:refresh");
|
||
|
|
window.dispatchEvent(event);
|
||
|
|
} catch (error) {
|
||
|
|
toast(error.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export 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 = t("status.research_running");
|
||
|
|
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(t("toast.research_completed"));
|
||
|
|
const event = new CustomEvent("workspace:refresh");
|
||
|
|
window.dispatchEvent(event);
|
||
|
|
} catch (error) {
|
||
|
|
$("researchResult").textContent = `${t("toast.research_failed")}: ${error.message}`;
|
||
|
|
toast(t("toast.research_failed"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export 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>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export 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(
|
||
|
|
[t("table.product"), t("table.type"), t("table.brand"), t("table.confidence")],
|
||
|
|
rows.map((row) => [row.product, row.product_type, row.brand ?? "", Math.round(row.confidence * 100) + "%"]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (kind === "products_by_tag") {
|
||
|
|
return table(
|
||
|
|
[t("table.product"), t("table.predicate"), t("table.tag"), t("table.confidence")],
|
||
|
|
rows.map((row) => [row.product, row.predicate, row.tag, Math.round(row.confidence * 100) + "%"]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (kind === "relation_summary") {
|
||
|
|
return table(
|
||
|
|
[t("table.predicate"), t("table.status"), t("table.support"), t("table.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(
|
||
|
|
[t("table.type"), t("table.count")],
|
||
|
|
rows.map((row) => [row.entity_type, row.count]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return table(
|
||
|
|
[t("table.predicate"), t("table.name"), t("table.type"), t("table.support"), t("table.confidence")],
|
||
|
|
rows.map((row) => [
|
||
|
|
row.predicate, row.name, row.entity_type, row.support_count,
|
||
|
|
Math.round((row.max_confidence ?? 0) * 100) + "%",
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function loadTags() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||
|
|
$("tagTable").innerHTML = table(
|
||
|
|
[t("table.predicate"), t("table.type"), t("table.name"), t("table.support"), t("table.confidence")],
|
||
|
|
tags.map((tag) => [
|
||
|
|
tag.predicate, tag.type, tag.name, tag.support_count,
|
||
|
|
Math.round((tag.max_confidence ?? 0) * 100) + "%",
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function recommend() {
|
||
|
|
if (!state.selectedProject) return;
|
||
|
|
try {
|
||
|
|
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(
|
||
|
|
[t("table.name"), t("table.type"), t("table.score"), t("table.reasons")],
|
||
|
|
result.map((item) => [item.name, item.entity_type, item.score, (item.reasons ?? []).join(", ")]),
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
toast(error.message);
|
||
|
|
}
|
||
|
|
}
|