[버그수정]
This commit is contained in:
@@ -8,10 +8,7 @@ const state = {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function csv(value) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -25,7 +22,7 @@ async function api(path, options = {}) {
|
||||
const body = await response.json();
|
||||
detail = body.detail || body.error || detail;
|
||||
} catch {
|
||||
// Keep the HTTP status text.
|
||||
// Keep status text.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
@@ -39,16 +36,15 @@ function toast(message) {
|
||||
window.setTimeout(() => node.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
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>${project.name}</strong><span>${project.domain}</span>`;
|
||||
button.addEventListener("click", () => selectProject(project.name));
|
||||
list.appendChild(button);
|
||||
});
|
||||
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() {
|
||||
@@ -62,6 +58,18 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
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)}`);
|
||||
@@ -77,6 +85,7 @@ function renderOverview() {
|
||||
$("metricProject").textContent = detail?.name ?? "-";
|
||||
$("metricDomain").textContent = detail?.domain ?? "-";
|
||||
$("metricSources").textContent = detail?.sources?.length ?? 0;
|
||||
|
||||
const sourceSelect = $("sourceSelect");
|
||||
sourceSelect.innerHTML = "";
|
||||
(detail?.sources ?? []).forEach((source) => {
|
||||
@@ -102,8 +111,7 @@ function renderOntology() {
|
||||
const predicates = state.ontology?.predicates ?? [];
|
||||
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||||
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||||
const filter = $("entityTypeFilter");
|
||||
filter.innerHTML = `<option value="">All types</option>${entityTypes
|
||||
$("entityTypeFilter").innerHTML = `<option value="">All types</option>${entityTypes
|
||||
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||||
.join("")}`;
|
||||
}
|
||||
@@ -115,40 +123,104 @@ async function createProject() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ config_path: configPath }),
|
||||
});
|
||||
toast(`프로젝트 생성: ${result.name}`);
|
||||
toast(`Project created: ${result.name}`);
|
||||
state.selectedProject = result.name;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function crawl() {
|
||||
if (!state.selectedProject) return;
|
||||
const sourceName = $("sourceSelect").value;
|
||||
const url = $("crawlUrl").value.trim();
|
||||
const provider = $("extractorProvider").value;
|
||||
const model = $("extractorModel").value.trim();
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
$("crawlResult").textContent = "수집 중...";
|
||||
$("crawlResult").textContent = "Crawling one URL...";
|
||||
try {
|
||||
const result = await api("/crawl", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: sourceName,
|
||||
url,
|
||||
extractor_provider: provider,
|
||||
extractor_model: model || null,
|
||||
extractor_base_url: baseUrl || null,
|
||||
}),
|
||||
body: JSON.stringify(requestBase()),
|
||||
});
|
||||
$("crawlResult").textContent = `analyzer ${provider}, page ${result.page_id}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||
toast("수집 완료");
|
||||
$("crawlResult").textContent = `URL done: page ${result.page_id}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||
toast("URL crawl completed");
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `수집 실패: ${error.message}`;
|
||||
toast("수집 실패");
|
||||
$("crawlResult").textContent = `Crawl failed: ${error.message}`;
|
||||
toast("Crawl failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function crawlSite() {
|
||||
if (!state.selectedProject) return;
|
||||
$("crawlResult").textContent = "Crawling site from seed...";
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const result = 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: ["product", "brand", "review"],
|
||||
}),
|
||||
});
|
||||
$("crawlResult").textContent =
|
||||
`Site done: visited ${result.visited_count}, analyzed ${result.analyzed_count}, skipped ${result.skipped_count}, queued ${result.queued_count}`;
|
||||
$("discoveredLinks").innerHTML = result.pages.map(renderSitePage).join("");
|
||||
toast("Site crawl completed");
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `Site crawl failed: ${error.message}`;
|
||||
toast("Site crawl failed");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSitePage(page) {
|
||||
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>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");
|
||||
@@ -164,17 +236,14 @@ function updateExtractorOptions() {
|
||||
async function testExtractor() {
|
||||
const provider = $("extractorProvider").value;
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
$("crawlResult").textContent = "분석기 연결 확인 중...";
|
||||
$("crawlResult").textContent = "Testing analyzer...";
|
||||
const result = await api("/extractors/models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
base_url: baseUrl || null,
|
||||
}),
|
||||
body: JSON.stringify({ provider, base_url: baseUrl || null }),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = `분석기 연결 실패: ${result.error}`;
|
||||
toast("분석기 연결 실패");
|
||||
$("crawlResult").textContent = `Analyzer failed: ${result.error}`;
|
||||
toast("Analyzer failed");
|
||||
return;
|
||||
}
|
||||
const models = result.models ?? [];
|
||||
@@ -182,51 +251,9 @@ async function testExtractor() {
|
||||
$("extractorModel").value = models[0].id;
|
||||
}
|
||||
$("crawlResult").textContent = models.length
|
||||
? `연결됨. 모델 ${models.length}개: ${models.map((model) => model.id).join(", ")}`
|
||||
: "연결됨. 모델 목록은 비어 있습니다.";
|
||||
toast("분석기 연결 확인 완료");
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
const sourceName = $("sourceSelect").value;
|
||||
const url = $("crawlUrl").value.trim();
|
||||
$("crawlResult").textContent = "주소 발견 중...";
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const result = await api("/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: sourceName,
|
||||
url,
|
||||
limit: 30,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = result.error ?? "주소 발견 실패";
|
||||
return;
|
||||
}
|
||||
$("crawlResult").textContent = `발견된 주소 ${result.links.length}개`;
|
||||
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast("URL 입력칸에 넣었습니다.");
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `주소 발견 실패: ${error.message}`;
|
||||
toast("주소 발견 실패");
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
? `Analyzer connected. Models: ${models.map((model) => model.id).join(", ")}`
|
||||
: "Analyzer connected. No models returned.";
|
||||
toast("Analyzer connected");
|
||||
}
|
||||
|
||||
async function loadEntities() {
|
||||
@@ -251,7 +278,7 @@ async function mergeEntities() {
|
||||
const sourceId = Number($("mergeSourceId").value);
|
||||
const targetId = Number($("mergeTargetId").value);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
toast("병합할 ID와 남길 ID를 확인하세요.");
|
||||
toast("Check entity IDs");
|
||||
return;
|
||||
}
|
||||
const result = await api("/entities/merge", {
|
||||
@@ -263,10 +290,10 @@ async function mergeEntities() {
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast(result.error ?? "병합 실패");
|
||||
toast(result.error ?? "Merge failed");
|
||||
return;
|
||||
}
|
||||
toast("Entity 병합 완료");
|
||||
toast("Entity merged");
|
||||
$("mergeSourceId").value = "";
|
||||
$("mergeTargetId").value = "";
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
@@ -296,7 +323,7 @@ function renderClaim(claim) {
|
||||
<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}">저장</button>
|
||||
<button data-save-claim="${claim.id}">Save</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
@@ -309,7 +336,7 @@ async function updateClaimConfidence(claimId) {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ confidence, reason }),
|
||||
});
|
||||
toast("신뢰도 수정 완료");
|
||||
toast("Claim updated");
|
||||
await loadClaims();
|
||||
}
|
||||
|
||||
@@ -357,16 +384,12 @@ function chip(value) {
|
||||
|
||||
function table(headers, rows) {
|
||||
if (!rows.length) {
|
||||
return `<table><tbody><tr><td>데이터가 없습니다.</td></tr></tbody></table>`;
|
||||
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>
|
||||
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${String(cell)}</td>`).join("")}</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
@@ -393,6 +416,7 @@ $("refreshBtn").addEventListener("click", loadProjects);
|
||||
$("createProjectBtn").addEventListener("click", createProject);
|
||||
$("discoverBtn").addEventListener("click", discover);
|
||||
$("crawlBtn").addEventListener("click", crawl);
|
||||
$("siteCrawlBtn").addEventListener("click", crawlSite);
|
||||
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||||
$("testExtractorBtn").addEventListener("click", testExtractor);
|
||||
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Ontology Crawler</h1>
|
||||
<p>프로젝트별 수집, Claim 검수, 온톨로지 매핑, 추천 태그 확인</p>
|
||||
<p>Project crawler, ontology mapping, claim review, and recommendation tags</p>
|
||||
</div>
|
||||
<button id="refreshBtn" class="icon-button" title="새로고침" aria-label="새로고침">↻</button>
|
||||
<button id="refreshBtn" class="icon-button" title="Refresh" aria-label="Refresh">R</button>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input id="configPath" value="configs/perfume_subscription.yaml" aria-label="Config path" />
|
||||
<button id="createProjectBtn" title="프로젝트 생성">+</button>
|
||||
<button id="createProjectBtn" title="Create project">+</button>
|
||||
</div>
|
||||
<div id="projectList" class="list"></div>
|
||||
</section>
|
||||
@@ -37,7 +37,7 @@
|
||||
<select id="sourceSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
URL
|
||||
URL / Seed URL
|
||||
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
|
||||
</label>
|
||||
<label>
|
||||
@@ -52,18 +52,33 @@
|
||||
<div class="extractor-options" id="extractorOptions">
|
||||
<label>
|
||||
Model
|
||||
<input id="extractorModel" placeholder="예: local model or API model" />
|
||||
<input id="extractorModel" placeholder="local or API model" />
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="extractorBaseUrl" placeholder="optional provider endpoint" />
|
||||
</label>
|
||||
<button id="testExtractorBtn">연결 테스트</button>
|
||||
<button id="testExtractorBtn">Test analyzer</button>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="discoverBtn">주소 발견</button>
|
||||
<button id="crawlBtn" class="primary">수집 실행</button>
|
||||
<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>
|
||||
<div id="crawlResult" class="mini-log"></div>
|
||||
<div id="discoveredLinks" class="discovered-links"></div>
|
||||
</section>
|
||||
@@ -120,26 +135,26 @@
|
||||
<section id="entities" class="tab-panel">
|
||||
<div class="toolbar wrap">
|
||||
<select id="entityTypeFilter"></select>
|
||||
<button id="loadEntitiesBtn">조회</button>
|
||||
<button id="loadEntitiesBtn">Load</button>
|
||||
</div>
|
||||
<div class="merge-bar">
|
||||
<input id="mergeSourceId" placeholder="병합할 Entity ID" aria-label="source entity id" />
|
||||
<input id="mergeTargetId" placeholder="남길 Entity ID" aria-label="target entity id" />
|
||||
<button id="mergeEntitiesBtn">병합</button>
|
||||
<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">
|
||||
<button id="loadClaimsBtn">Claim 새로고침</button>
|
||||
<button id="loadClaimsBtn">Refresh claims</button>
|
||||
</div>
|
||||
<div id="claimTable" class="claim-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tags" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadTagsBtn">태그 조회</button>
|
||||
<button id="loadTagsBtn">Load tags</button>
|
||||
</div>
|
||||
<div id="tagTable" class="table"></div>
|
||||
</section>
|
||||
@@ -167,7 +182,7 @@
|
||||
<input id="occasionContext" value="Daily" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="recommendBtn" class="primary">추천 테스트</button>
|
||||
<button id="recommendBtn" class="primary">Test recommendation</button>
|
||||
<div id="recommendTable" class="table"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -194,6 +194,29 @@ h2 {
|
||||
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;
|
||||
@@ -446,7 +469,8 @@ th {
|
||||
.split,
|
||||
.recommend-grid,
|
||||
.claim-actions,
|
||||
.merge-bar {
|
||||
.merge-bar,
|
||||
.site-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user