[crawler_platform 삭제]

This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 13:21:08 +09:00
parent be717a8f9a
commit fc69dfd063
173 changed files with 307 additions and 1982 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ crawler_platform.db-*
*.sqlite-*
*.sqlite3-*
uvicorn.*.log
.server-logs/

View File

@@ -1,812 +0,0 @@
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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));

View File

@@ -1,281 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ontology Crawler Platform</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="topbar">
<div>
<h1>Ontology Crawler</h1>
<p>Project crawler, ontology mapping, claim review, and recommendation tags</p>
</div>
<button id="refreshBtn" class="icon-button" title="Refresh" aria-label="Refresh">R</button>
</header>
<main class="layout">
<aside class="sidebar">
<section class="panel">
<div class="panel-head">
<h2>Projects</h2>
</div>
<div class="field-row">
<input id="configPath" value="configs/perfume_subscription.yaml" aria-label="Config path" />
<button id="createProjectBtn" title="Create project">+</button>
</div>
<button id="initProjectBtn" class="full">Reset current project data</button>
<div id="projectList" class="list"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Crawl</h2>
</div>
<label>
Source
<select id="sourceSelect"></select>
</label>
<label>
URL / Seed URL
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
</label>
<label>
Analyzer
<select id="extractorProvider">
<option value="rule_based">Rule-based</option>
<option value="openai">OpenAI API</option>
<option value="ollama">Ollama</option>
<option value="lm_studio" selected>LM Studio</option>
</select>
</label>
<div class="extractor-options" id="extractorOptions">
<label>
Model
<input id="extractorModel" placeholder="local or API model" />
</label>
<label>
Base URL
<input id="extractorBaseUrl" value="http://localhost:1234/v1" placeholder="optional provider endpoint" />
</label>
<button id="testExtractorBtn">Test analyzer</button>
</div>
<div class="button-grid">
<button id="discoverBtn">Discover links</button>
<button id="crawlBtn" class="primary">Crawl URL</button>
</div>
<div class="site-controls">
<label>
Max depth
<input id="siteMaxDepth" type="number" min="0" max="5" value="2" />
</label>
<label>
Max pages
<input id="siteMaxPages" type="number" min="1" max="500" value="25" />
</label>
<label class="check-row">
<input id="sameDomainOnly" type="checkbox" checked />
Same domain
</label>
</div>
<button id="siteCrawlBtn" class="primary full">Crawl site from seed</button>
<button id="stopSiteCrawlBtn" class="full" disabled>Stop current crawl</button>
<div id="crawlResult" class="mini-log"></div>
<div id="discoveredLinks" class="discovered-links"></div>
</section>
</aside>
<section class="workspace">
<nav class="tabs" aria-label="Admin sections">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="ontology">Ontology</button>
<button class="tab" data-tab="entities">Entities</button>
<button class="tab" data-tab="claims">Claims</button>
<button class="tab" data-tab="debug">Debug</button>
<button class="tab" data-tab="research">Research</button>
<button class="tab" data-tab="tags">Tags</button>
<button class="tab" data-tab="recommend">Recommend</button>
</nav>
<section id="overview" class="tab-panel active">
<div class="summary-grid">
<div class="metric">
<span>Project</span>
<strong id="metricProject">-</strong>
</div>
<div class="metric">
<span>Domain</span>
<strong id="metricDomain">-</strong>
</div>
<div class="metric">
<span>Sources</span>
<strong id="metricSources">0</strong>
</div>
<div class="metric">
<span>Entities</span>
<strong id="metricEntities">0</strong>
</div>
</div>
<div class="wide-panel">
<h2>Sources</h2>
<div id="sourceTable" class="table"></div>
</div>
</section>
<section id="ontology" class="tab-panel">
<div class="toolbar">
<button id="loadOntologyRegistryBtn">Refresh registry</button>
</div>
<div class="split">
<div class="wide-panel">
<h2>Configured Entity Types</h2>
<div id="ontologyEntities" class="chips"></div>
</div>
<div class="wide-panel">
<h2>Configured Predicates</h2>
<div id="ontologyPredicates" class="chips"></div>
</div>
</div>
<div class="split">
<div class="wide-panel">
<h2>Registry Entity Types</h2>
<div id="ontologyRegistryEntityTable" class="table"></div>
</div>
<div class="wide-panel">
<h2>Registry Relation Types</h2>
<div id="ontologyRegistryRelationTable" class="table"></div>
</div>
</div>
<div class="wide-panel">
<h2>Ontology Triples</h2>
<div id="ontologyTripleTable" class="table"></div>
</div>
<div class="split">
<div class="wide-panel">
<h2>Schema Proposals</h2>
<div id="ontologyProposalTable" class="table"></div>
</div>
<div class="wide-panel">
<h2>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">Load</button>
</div>
<div class="merge-bar">
<input id="mergeSourceId" placeholder="Entity ID to merge" aria-label="source entity id" />
<input id="mergeTargetId" placeholder="Entity ID to keep" aria-label="target entity id" />
<button id="mergeEntitiesBtn">Merge</button>
</div>
<div id="entityTable" class="table"></div>
</section>
<section id="claims" class="tab-panel">
<div class="toolbar">
<label class="check-row">
<input id="showCandidateClaims" type="checkbox" />
Show candidates
</label>
<button id="loadClaimsBtn">Refresh claims</button>
</div>
<div id="claimTable" class="claim-list"></div>
</section>
<section id="debug" class="tab-panel">
<div class="toolbar">
<button id="loadExtractionLogsBtn">Refresh extraction logs</button>
</div>
<div id="extractionLogTable" class="claim-list"></div>
</section>
<section id="research" class="tab-panel">
<div class="wide-panel">
<h2>Semantic Exploration</h2>
<div class="recommend-grid">
<label>
Goal
<input id="researchGoal" value="Forment perfume ecosystem exploration" />
</label>
<label>
Seed entity ID
<input id="researchSeedEntityId" placeholder="optional entity id" />
</label>
<label>
Max steps
<input id="researchMaxSteps" type="number" min="1" max="50" value="8" />
</label>
<label>
Min relevance
<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">Run graph research</button>
<button id="loadResearchBtn">Load sessions</button>
</div>
<div id="researchResult" class="mini-log"></div>
<div id="researchSessions" class="claim-list"></div>
</div>
<div class="wide-panel">
<h2>Graph Query</h2>
<div class="toolbar wrap">
<select id="graphQueryKind">
<option value="trend_summary">Trend summary</option>
<option value="brand_products">Brand products</option>
<option value="products_by_tag">Products by tag</option>
<option value="relation_summary">Relation summary</option>
<option value="entity_type_summary">Entity type summary</option>
</select>
<input id="graphQueryText" placeholder="brand or tag filter" />
<button id="loadGraphQueryBtn">Run query</button>
</div>
<div id="graphQueryTable" class="table"></div>
</div>
</section>
<section id="tags" class="tab-panel">
<div class="toolbar">
<button id="loadTagsBtn">Load tags</button>
</div>
<div id="tagTable" class="table"></div>
</section>
<section id="recommend" class="tab-panel">
<div class="recommend-grid">
<label>
Preferred notes
<input id="preferredNotes" value="Bergamot, Musk" />
</label>
<label>
Avoided notes
<input id="avoidedNotes" value="" />
</label>
<label>
Preferred moods
<input id="preferredMoods" value="Fresh" />
</label>
<label>
Season
<input id="seasonContext" value="Summer" />
</label>
<label>
Occasion
<input id="occasionContext" value="Daily" />
</label>
</div>
<button id="recommendBtn" class="primary">Test recommendation</button>
<div id="recommendTable" class="table"></div>
</section>
</section>
</main>
<div id="toast" role="status" aria-live="polite"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -1,480 +0,0 @@
:root {
color-scheme: light;
--bg: #f6f7f4;
--surface: #ffffff;
--surface-2: #eef3ee;
--text: #202420;
--muted: #667063;
--line: #d8ded6;
--accent: #236b5b;
--accent-2: #9c4f30;
--danger: #a33434;
--shadow: 0 16px 40px rgba(24, 35, 28, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", Arial, sans-serif;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
}
button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
border-radius: 6px;
min-height: 36px;
padding: 0 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
input,
select {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
background: white;
padding: 0 10px;
color: var(--text);
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.86);
position: sticky;
top: 0;
z-index: 5;
backdrop-filter: blur(12px);
}
.topbar h1 {
margin: 0;
font-size: 22px;
}
.topbar p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.icon-button {
width: 40px;
padding: 0;
font-size: 20px;
}
.layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 18px;
padding: 18px;
}
.sidebar,
.workspace {
min-width: 0;
}
.sidebar {
display: grid;
align-content: start;
gap: 14px;
}
.panel,
.wide-panel,
.metric {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.panel,
.wide-panel {
padding: 14px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
h2 {
margin: 0;
font-size: 15px;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
}
#initProjectBtn {
margin-top: 8px;
}
.list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.project-item {
display: grid;
gap: 2px;
text-align: left;
height: auto;
min-height: 52px;
padding: 8px 10px;
}
.project-item.active {
border-color: var(--accent);
background: var(--surface-2);
}
.project-item strong {
font-size: 14px;
}
.project-item span {
color: var(--muted);
font-size: 12px;
}
.mini-log {
min-height: 38px;
margin-top: 10px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button.full {
width: 100%;
}
.site-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: end;
}
.check-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
}
.check-row input {
width: 16px;
min-height: 16px;
}
.extractor-options {
display: none;
gap: 8px;
}
.extractor-options.active {
display: grid;
}
.extractor-options button {
width: 100%;
}
.discovered-links {
display: grid;
gap: 6px;
margin-top: 10px;
max-height: 280px;
overflow: auto;
}
.discovered-link {
display: grid;
gap: 2px;
min-height: 44px;
padding: 7px 8px;
text-align: left;
overflow-wrap: anywhere;
}
.discovered-link span {
color: var(--muted);
font-size: 11px;
}
.tabs {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 10px;
}
.tab {
white-space: nowrap;
}
.tab.active {
background: var(--text);
color: white;
border-color: var(--text);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: grid;
gap: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px;
}
.metric {
padding: 14px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 20px;
overflow-wrap: anywhere;
}
.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
font-size: 12px;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
}
.toolbar.wrap {
flex-wrap: wrap;
}
.toolbar select {
max-width: 240px;
}
.merge-bar {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(120px, 180px) 80px;
gap: 8px;
}
.table {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
background: #fafbf8;
font-size: 12px;
}
.claim-list {
display: grid;
gap: 10px;
}
.claim-card {
display: grid;
gap: 8px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.claim-main {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.predicate {
color: var(--accent);
font-weight: 800;
}
.confidence {
margin-left: auto;
color: var(--accent-2);
font-weight: 800;
}
.evidence {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.claim-actions {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) 90px;
gap: 8px;
}
.recommend-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 10px;
}
#toast {
position: fixed;
right: 18px;
bottom: 18px;
max-width: min(420px, calc(100vw - 36px));
padding: 12px 14px;
border-radius: 8px;
background: var(--text);
color: white;
opacity: 0;
transform: translateY(10px);
transition: 180ms ease;
pointer-events: none;
font-size: 13px;
}
#toast.show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
.summary-grid,
.split,
.recommend-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 560px) {
.topbar {
padding: 14px;
}
.layout {
padding: 12px;
}
.summary-grid,
.split,
.recommend-grid,
.claim-actions,
.merge-bar,
.site-controls {
grid-template-columns: 1fr;
}
}

View File

@@ -1,2 +0,0 @@
"""Admin web UI."""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Ontology Construction Platform with Phase 5 GraphRAG and Phase 7 LLM" />
<title>Ontology Builder - AI-Powered Ontology Construction</title>
<script type="module" crossorigin src="/static/assets/index-B9qLr-xp.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CAk04EKS.css">
</head>
<body>
<div id="root"></div>
</body>

View File

@@ -1,172 +0,0 @@
{
"app": {
"title": "Ontology Builder",
"subtitle": "AI-powered ontology construction"
},
"nav": {
"dashboard": "Dashboard",
"onboard": "New Project",
"sources": "Sources",
"crawl": "Crawl",
"research": "Research",
"editor": "Editor",
"review": "Review",
"toggleSidebar": "Toggle sidebar"
},
"dashboard": {
"title": "Ontology Builder",
"subtitle": "Build and manage domain ontologies with AI-powered extraction",
"newProject": "New Project",
"projects": "Projects",
"projectCount": "{{count}} total",
"loadFailed": "Failed to load projects",
"empty": {
"title": "No projects yet",
"hint": "Create your first project to start building an ontology"
}
},
"onboarding": {
"title": "Create New Project",
"formTitle": "Choose Ontology Domain",
"formDesc": "Pick the domain of ontology you want to build and give your project a name.",
"projectName": "Project Name",
"projectNameHint": "Letters, digits, _ and - only (2~64 chars)",
"domain": "Domain",
"domainSummary": "{{entities}} entity types · {{predicates}} predicates",
"submit": "Create Project",
"created": "Project created: {{name}}",
"createFailed": "Create failed: {{msg}}"
},
"sources": {
"title": "Configure Sources",
"next": "Proceed to Crawl",
"listTitle": "Registered Sources",
"listDesc": "Reference sites used for ontology construction",
"empty": "No sources yet. Add one using the form on the right.",
"addTitle": "Add Source",
"addDesc": "Enter information about the reference site",
"name": "Name",
"type": "Type",
"baseUrl": "Base URL",
"trust": "Trust",
"rateLimit": "rate/min",
"respectRobots": "Respect robots.txt",
"add": "Add Source",
"delete": "Delete",
"confirmDelete": "Delete source '{{name}}'?",
"added": "Source added: {{name}}",
"addFailed": "Add failed: {{msg}}",
"deleted": "Source deleted: {{name}}",
"deleteFailed": "Delete failed: {{msg}}"
},
"research": {
"title": "Autonomous Research",
"formTitle": "Research Settings",
"formDesc": "AI follows links from a seed to autonomously expand the ontology",
"source": "Source",
"pickSource": "Pick a source...",
"goal": "Goal",
"goalPlaceholder": "e.g. Collect note compositions and seasonal recommendations of popular perfume brands",
"seedUrl": "Seed URL",
"optional": "optional",
"maxSteps": "Max Steps",
"maxBranch": "Branch Width",
"maxDepth": "Max Depth",
"minRelevance": "Min Relevance",
"sameDomainOnly": "Same domain only",
"start": "Start Research",
"runningHint": "This may take a while. Don't close the page until it finishes.",
"runningTitle": "AI is researching...",
"completed": "Research completed",
"failed": "Failed: {{msg}}",
"doneHint": "Done",
"idleHint": "Start research on the left to see results here",
"resultTitle": "Latest Result",
"resultDesc": "Outcome of the research run in this session",
"stepsTaken": "Steps",
"pagesVisited": "Pages",
"entitiesFound": "Entities",
"claimsAdded": "Claims",
"rawResult": "Raw JSON",
"historyTitle": "Session History",
"historyDesc": "Past research sessions for this project",
"historyEmpty": "No sessions yet",
"pages": "pages"
},
"editor": {
"title": "Ontology Editor",
"entitiesTab": "Entities",
"claimsTab": "Claims",
"bulkTab": "JSON Bulk",
"addEntity": "Add Entity",
"addEntityDesc": "Pick from the domain's entity_types",
"entityType": "Type",
"pickType": "Pick type...",
"entityName": "Name",
"add": "Add",
"entitiesList": "Entities",
"entityCount": "{{count}} total",
"entitiesEmpty": "No entities yet",
"entityAdded": "Entity added: {{name}}",
"entityAddFailed": "Add failed: {{msg}}",
"confirmDeleteEntity": "Delete entity '{{name}}'?",
"addClaim": "Add Claim",
"addClaimDesc": "Subject-Predicate-Object form",
"source": "Source",
"pickSource": "Pick source...",
"subject": "Subject",
"pickSubject": "Pick entity...",
"predicate": "Predicate",
"pickPredicate": "Pick predicate...",
"objectKind": "Object kind",
"objectEntity": "Other entity",
"objectValue": "Literal value",
"pickObject": "Pick entity...",
"confidence": "Confidence",
"claimsList": "Claims",
"claimCount": "{{count}} total",
"claimsEmpty": "No claims yet",
"claimAdded": "Claim added",
"claimAddFailed": "Add failed: {{msg}}",
"confirmDeleteClaim": "Delete this claim?",
"bulkTitle": "JSON Bulk Input",
"bulkDesc": "JSON of shape { entities: [{ entity_type, name, metadata? }] }",
"bulkSubmit": "Bulk Add",
"bulkAdded": "{{count}} entities added"
},
"crawl": {
"title": "Seed Crawl",
"formTitle": "Crawl Settings",
"formDesc": "Start from a seed URL and follow links to extract information",
"source": "Source",
"pickSource": "Pick a source...",
"noSources": "No sources registered. Add a reference source first.",
"addSource": "Add Source",
"seedUrl": "Seed URL",
"maxDepth": "Max Depth",
"maxPages": "Max Pages",
"sameDomainOnly": "Same domain only",
"start": "Start Crawl",
"started": "Crawl started (job #{{id}})",
"startFailed": "Start failed: {{msg}}",
"cancel": "Cancel",
"cancelRequested": "Cancel requested",
"cancelFailed": "Cancel failed: {{msg}}",
"progressTitle": "Progress",
"idleHint": "Enter a seed URL and start the crawl",
"visited": "Visited",
"queued": "Queued",
"analyzed": "Analyzed",
"latestPage": "Latest Page",
"errorsCount": "{{count}} errors",
"doneHint": "Crawl complete. Go review the results.",
"review": "Review"
},
"common": {
"retry": "Retry",
"cancel": "Cancel",
"next": "Next",
"back": "Back",
"complete": "Complete"
}
}

View File

@@ -1,172 +0,0 @@
{
"app": {
"title": "온톨로지 빌더",
"subtitle": "AI 기반 온톨로지 구축 플랫폼"
},
"nav": {
"dashboard": "대시보드",
"onboard": "프로젝트 생성",
"sources": "참고 소스",
"crawl": "크롤 진행",
"research": "자율 연구",
"editor": "온톨로지 편집",
"review": "결과 검토",
"toggleSidebar": "사이드바 토글"
},
"dashboard": {
"title": "온톨로지 빌더",
"subtitle": "도메인 온톨로지를 AI 추출로 구축하고 관리합니다",
"newProject": "새 프로젝트",
"projects": "프로젝트 목록",
"projectCount": "{{count}}개",
"loadFailed": "프로젝트를 불러오지 못했습니다",
"empty": {
"title": "아직 프로젝트가 없습니다",
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
}
},
"onboarding": {
"title": "새 프로젝트 만들기",
"formTitle": "온톨로지 도메인 선택",
"formDesc": "어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
"projectName": "프로젝트 이름",
"projectNameHint": "영문, 숫자, _ , - 만 사용 (2~64자)",
"domain": "도메인",
"domainSummary": "엔티티 {{entities}}종 · 관계 {{predicates}}개",
"submit": "프로젝트 만들기",
"created": "프로젝트가 생성되었습니다: {{name}}",
"createFailed": "생성 실패: {{msg}}"
},
"sources": {
"title": "참고 소스 설정",
"next": "크롤 진행",
"listTitle": "등록된 소스",
"listDesc": "프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
"empty": "아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
"addTitle": "소스 추가",
"addDesc": "참고할 사이트 정보를 입력하세요",
"name": "이름",
"type": "타입",
"baseUrl": "Base URL",
"trust": "신뢰도",
"rateLimit": "rate/분",
"respectRobots": "robots.txt 준수",
"add": "소스 추가",
"delete": "삭제",
"confirmDelete": "정말 '{{name}}' 소스를 삭제하시겠습니까?",
"added": "소스가 추가되었습니다: {{name}}",
"addFailed": "추가 실패: {{msg}}",
"deleted": "소스가 삭제되었습니다: {{name}}",
"deleteFailed": "삭제 실패: {{msg}}"
},
"research": {
"title": "자율 연구",
"formTitle": "자율 연구 설정",
"formDesc": "AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
"source": "참고 소스",
"pickSource": "소스를 선택하세요...",
"goal": "목표",
"goalPlaceholder": "예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
"seedUrl": "시드 URL",
"optional": "선택",
"maxSteps": "최대 단계",
"maxBranch": "분기 폭",
"maxDepth": "최대 깊이",
"minRelevance": "최소 관련도",
"sameDomainOnly": "동일 도메인만 탐색",
"start": "자율 연구 시작",
"runningHint": "장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
"runningTitle": "AI가 연구 중입니다...",
"completed": "자율 연구가 완료되었습니다",
"failed": "실패: {{msg}}",
"doneHint": "완료",
"idleHint": "왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
"resultTitle": "최근 결과",
"resultDesc": "이번 세션에서 실행된 연구의 결과",
"stepsTaken": "단계",
"pagesVisited": "페이지",
"entitiesFound": "엔티티",
"claimsAdded": "클레임",
"rawResult": "원시 응답 JSON",
"historyTitle": "세션 이력",
"historyDesc": "이 프로젝트의 자율 연구 세션 기록",
"historyEmpty": "아직 실행된 세션이 없습니다",
"pages": "페이지"
},
"editor": {
"title": "온톨로지 직접 편집",
"entitiesTab": "엔티티",
"claimsTab": "클레임",
"bulkTab": "JSON 일괄 입력",
"addEntity": "엔티티 추가",
"addEntityDesc": "온톨로지 도메인의 entity_types 중에서 선택",
"entityType": "타입",
"pickType": "타입 선택...",
"entityName": "이름",
"add": "추가",
"entitiesList": "엔티티 목록",
"entityCount": "{{count}}개",
"entitiesEmpty": "아직 등록된 엔티티가 없습니다",
"entityAdded": "엔티티가 추가되었습니다: {{name}}",
"entityAddFailed": "추가 실패: {{msg}}",
"confirmDeleteEntity": "엔티티 '{{name}}'을 삭제하시겠습니까?",
"addClaim": "클레임 추가",
"addClaimDesc": "주어-술어-목적어 형태로 직접 입력",
"source": "소스",
"pickSource": "소스 선택...",
"subject": "주어 (Subject)",
"pickSubject": "엔티티 선택...",
"predicate": "술어 (Predicate)",
"pickPredicate": "술어 선택...",
"objectKind": "목적어 유형",
"objectEntity": "다른 엔티티",
"objectValue": "리터럴 값",
"pickObject": "엔티티 선택...",
"confidence": "신뢰도",
"claimsList": "클레임 목록",
"claimCount": "{{count}}개",
"claimsEmpty": "아직 등록된 클레임이 없습니다",
"claimAdded": "클레임이 추가되었습니다",
"claimAddFailed": "추가 실패: {{msg}}",
"confirmDeleteClaim": "클레임을 삭제하시겠습니까?",
"bulkTitle": "JSON 일괄 입력",
"bulkDesc": "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
"bulkSubmit": "일괄 추가",
"bulkAdded": "{{count}}개 엔티티가 추가되었습니다"
},
"crawl": {
"title": "시드 크롤",
"formTitle": "크롤 설정",
"formDesc": "시드 URL에서 시작해 링크를 따라가며 정보를 추출합니다",
"source": "참고 소스",
"pickSource": "소스를 선택하세요...",
"noSources": "등록된 소스가 없습니다. 먼저 참고 소스를 추가하세요.",
"addSource": "소스 추가",
"seedUrl": "시드 URL",
"maxDepth": "최대 깊이",
"maxPages": "최대 페이지",
"sameDomainOnly": "동일 도메인만 따라가기",
"start": "크롤 시작",
"started": "크롤이 시작되었습니다 (job #{{id}})",
"startFailed": "시작 실패: {{msg}}",
"cancel": "취소",
"cancelRequested": "취소 요청됨",
"cancelFailed": "취소 실패: {{msg}}",
"progressTitle": "진행 상태",
"idleHint": "왼쪽에서 시드 URL을 입력하고 시작하세요",
"visited": "방문",
"queued": "대기",
"analyzed": "분석",
"latestPage": "최근 페이지",
"errorsCount": "에러 {{count}}건",
"doneHint": "크롤 완료. 결과 검토로 이동하세요.",
"review": "결과 검토"
},
"common": {
"retry": "다시 시도",
"cancel": "취소",
"next": "다음",
"back": "이전",
"complete": "완료"
}
}

View File

@@ -54,6 +54,7 @@ data/working/
data/cache/
data/raw/
data/artifacts/
data/ui_projects.json
# ─── 데이터베이스 ───────────────────────────────────────────────────
*.db
@@ -81,3 +82,7 @@ docker-compose.override.yml
# ─── OS ─────────────────────────────────────────────────────────────
Thumbs.db
Desktop.ini
# Frontend
web/frontend/node_modules/
web/static/

View File

@@ -0,0 +1,113 @@
project_name: perfume_subscription
domain: perfume
target_entities:
- Product
- Perfume
- Brand
- Event
- Article
- Promotion
- Category
- Notice
- Page
- Note
- Accord
- Mood
- Season
- Occasion
fields:
- name
- brand
- top_notes
- middle_notes
- base_notes
- accords
- mood_tags
- season_tags
- occasion_tags
- price
- review_keywords
sources:
- name: official_brand_site
type: official
trust_level: 0.95
parser: generic
fetcher: playwright
rate_limit_per_minute: 20
respect_robots_txt: false
- name: marketplace
type: marketplace
trust_level: 0.8
parser: generic
fetcher: requests
rate_limit_per_minute: 15
respect_robots_txt: false
- name: review_site
type: review
trust_level: 0.7
parser: generic
fetcher: requests
rate_limit_per_minute: 10
respect_robots_txt: false
ontology:
entity_types:
- Product
- Perfume
- Brand
- Event
- Article
- Promotion
- Category
- Notice
- Page
- Note
- Accord
- Mood
- Season
- Occasion
- Review
- Price
- ProductPage
- CommunityPage
- BrandStoryPage
- ListingPage
- PromotionPage
- ReviewPage
predicates:
- hasBrand
- hasTopNote
- hasMiddleNote
- hasBaseNote
- hasAccord
- evokesMood
- suitableForSeason
- suitableForOccasion
- similarTo
- soldBy
- hasPrice
- hasReviewKeyword
aliases:
top_notes: hasTopNote
middle_notes: hasMiddleNote
heart_notes: hasMiddleNote
base_notes: hasBaseNote
accords: hasAccord
mood_tags: evokesMood
season_tags: suitableForSeason
occasion_tags: suitableForOccasion
review_keywords: hasReviewKeyword
recommendation:
target_entity_type: Perfume
weights:
preferred_note: 2.0
avoided_note: -3.0
preferred_mood: 1.5
season_context: 1.2
occasion_context: 1.0
update_policy:
official:
interval_days: 30
marketplace:
interval_days: 7
review:
interval_days: 14

View File

@@ -10,8 +10,12 @@ from crawler_platform.app.api.routes import register_routes
from crawler_platform.app.core.database.session import init_db
DATABASE_URL = os.getenv("CRAWLER_DATABASE_URL", "sqlite:///crawler_platform.db")
STATIC_DIR = Path(__file__).parent / "web" / "static"
ONTOLOGY_ROOT = Path(__file__).resolve().parents[2]
DATABASE_URL = os.getenv(
"CRAWLER_DATABASE_URL",
f"sqlite:///{(ONTOLOGY_ROOT / 'data' / 'crawler_platform.db').as_posix()}",
)
STATIC_DIR = Path(os.getenv("ONTOLOGY_PRODUCT_STATIC_DIR", str(ONTOLOGY_ROOT / "web" / "static")))
app = FastAPI(title="Ontology Crawler Platform", version="0.1.0")
init_db(DATABASE_URL)

View File

@@ -44,6 +44,7 @@ from ont_platform.api.deps import ( # noqa: E402
get_app_context,
initialize_app_context,
)
from ont_platform.api.product_backend import include_product_backend # noqa: E402
platform_config = importlib.import_module("ont_platform.config")
@@ -80,7 +81,7 @@ PLATFORM_VERSION = "0.0.1"
def _include_phase_routers(app: FastAPI) -> None:
"""Attach routers whose dependencies are enabled for the configured phase."""
settings = platform_config.load_settings()
enabled_routes: list[str] = []
enabled_routes: list[str] = ["product-backend"]
if settings.phase >= platform_config.Phase.TRAFILATURA:
try:
@@ -144,6 +145,7 @@ def create_app() -> FastAPI:
version=PLATFORM_VERSION,
lifespan=lifespan,
)
include_product_backend(app)
# ─── /health ──────────────────────────────────────────────────────
@app.get("/health", tags=["meta"])

View File

@@ -0,0 +1,87 @@
"""Product backend bridge for the migrated crawler platform.
This module mounts the product API that the current frontend already uses:
projects, sources, crawl jobs, claims, ontology registry, graph views, and
exports. The implementation now lives inside ``ontology_platform`` so the root
``crawler_platform`` folder can be retired after verification.
"""
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from crawler_platform.app.api.routes import register_routes
from crawler_platform.app.core.database.session import init_db
_ONTOLOGY_ROOT = Path(__file__).resolve().parents[2]
_DEFAULT_DB_PATH = _ONTOLOGY_ROOT / "data" / "crawler_platform.db"
_DEFAULT_STATIC_DIR = _ONTOLOGY_ROOT / "web" / "static"
def product_database_url() -> str:
configured = os.getenv("CRAWLER_DATABASE_URL")
if configured:
return configured
_DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{_DEFAULT_DB_PATH.as_posix()}"
def product_static_dir() -> Path:
configured = os.getenv("ONTOLOGY_PRODUCT_STATIC_DIR")
return Path(configured) if configured else _DEFAULT_STATIC_DIR
def include_product_backend(app: FastAPI) -> None:
"""Mount the migrated product API and SPA routes on the given app."""
database_url = product_database_url()
init_db(database_url)
register_routes(app, database_url)
_remove_route(app, "/health", {"GET"})
app.state.product_database_url = database_url
app.add_exception_handler(KeyError, _handle_key_error)
@app.get("/static/{asset_path:path}", include_in_schema=False)
def static_or_spa(asset_path: str):
static_root = product_static_dir().resolve()
target = (static_root / asset_path).resolve()
try:
target.relative_to(static_root)
except ValueError:
return JSONResponse(status_code=404, content={"detail": "not found"})
if target.is_file():
return FileResponse(target)
index = static_root / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(status_code=404, content={"detail": "frontend build not found"})
@app.get("/", include_in_schema=False)
def admin_ui():
index = product_static_dir() / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(status_code=404, content={"detail": "frontend build not found"})
def _handle_key_error(_request: Request, exc: KeyError) -> JSONResponse:
detail = str(exc.args[0]) if exc.args else "not found"
return JSONResponse(status_code=404, content={"detail": detail})
def _remove_route(app: FastAPI, path: str, methods: set[str]) -> None:
app.router.routes = [
route
for route in app.router.routes
if not (
getattr(route, "path", None) == path
and set(getattr(route, "methods", set())) == methods
)
]

View File

@@ -117,13 +117,13 @@ dev = [
ontology-platform = "ont_platform.api.main:cli"
[tool.hatch.build.targets.wheel]
packages = ["ont_platform"]
packages = ["ont_platform", "crawler_platform"]
# ─── Ruff (linter + formatter) ────────────────────────────────────────
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["ont_platform", "tests"]
src = ["ont_platform", "crawler_platform", "tests"]
extend-exclude = ["vendored"] # vendored OntoCast 등은 원본 유지
[tool.ruff.lint]

View File

@@ -1,11 +1,11 @@
{
"name": "crawler-platform-ui",
"name": "ontology-platform-ui",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crawler-platform-ui",
"name": "ontology-platform-ui",
"version": "0.2.0",
"dependencies": {
"@hookform/resolvers": "^3.3.4",

View File

@@ -1,5 +1,5 @@
{
"name": "crawler-platform-ui",
"name": "ontology-platform-ui",
"version": "0.2.0",
"private": true,
"type": "module",

Some files were not shown because too many files have changed in this diff Show More