domain builder
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
@@ -28,7 +29,8 @@ from crawler_platform.app.core.database.repository import (
|
||||
from crawler_platform.app.core.database.session import session_scope
|
||||
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
|
||||
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
||||
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, ontology_for_domain
|
||||
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, Ontology, ontology_for_domain
|
||||
from crawler_platform.app.core.ontology.domain_discovery import DomainDiscoveryService
|
||||
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
|
||||
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
||||
from crawler_platform.app.core.ontology.registry import OntologyRegistry
|
||||
@@ -38,6 +40,9 @@ from crawler_platform.app.core.research.graph_research_loop import GraphResearch
|
||||
from crawler_platform.app.core.research.memory_store import ResearchMemoryStore, research_session_payload
|
||||
|
||||
|
||||
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,79}$")
|
||||
|
||||
|
||||
class CrawlRequest(BaseModel):
|
||||
config_path: str
|
||||
source_name: str
|
||||
@@ -149,6 +154,41 @@ class CreateProjectInlineRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DomainDefinitionRequest(BaseModel):
|
||||
domain: str
|
||||
description: str | None = None
|
||||
entity_types: list[str] = Field(default_factory=list)
|
||||
predicates: list[str] = Field(default_factory=list)
|
||||
attributes: list[str] = Field(default_factory=list)
|
||||
aliases: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DomainDefinitionUpdateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
entity_types: list[str] = Field(default_factory=list)
|
||||
predicates: list[str] = Field(default_factory=list)
|
||||
attributes: list[str] = Field(default_factory=list)
|
||||
aliases: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DomainDiscoveryRequest(BaseModel):
|
||||
seed_urls: list[str] = Field(default_factory=list)
|
||||
max_pages: int = 20
|
||||
max_depth: int = 1
|
||||
same_domain_only: bool = True
|
||||
fetcher: str = "requests"
|
||||
respect_robots_txt: bool = False
|
||||
force_recrawl: bool = False
|
||||
|
||||
|
||||
class DomainCandidateApplyRequest(BaseModel):
|
||||
candidate_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DomainCandidateStatusRequest(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class ResetProjectRequest(BaseModel):
|
||||
config_path: str
|
||||
project_name: str | None = None
|
||||
@@ -165,6 +205,250 @@ def source_model_to_config(source: models.Source) -> SourceConfig:
|
||||
)
|
||||
|
||||
|
||||
def normalize_domain_name(domain: str) -> str:
|
||||
normalized = domain.strip().lower()
|
||||
if not DOMAIN_NAME_PATTERN.match(normalized):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Domain must be 2-80 chars using lowercase letters, numbers, underscore, or hyphen.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def clean_string_list(values: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
item = value.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
cleaned.append(item)
|
||||
seen.add(item)
|
||||
return cleaned
|
||||
|
||||
|
||||
def clean_aliases(values: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key.strip(): value.strip()
|
||||
for key, value in values.items()
|
||||
if key.strip() and value.strip()
|
||||
}
|
||||
|
||||
|
||||
def domain_row_to_ontology(row: models.DomainDefinition):
|
||||
return Ontology(
|
||||
domain=row.domain,
|
||||
entity_types=clean_string_list(list(row.entity_types or [])),
|
||||
predicates=clean_string_list(list(row.predicates or [])),
|
||||
attributes=clean_string_list(list(row.attributes or [])),
|
||||
aliases=clean_aliases(dict(row.aliases or {})),
|
||||
)
|
||||
|
||||
|
||||
def domain_summary_payload(ont, *, is_builtin: bool, is_custom: bool, description: str | None = None):
|
||||
return {
|
||||
"domain": ont.domain,
|
||||
"description": description,
|
||||
"entity_types": list(ont.entity_types),
|
||||
"predicates": list(ont.predicates),
|
||||
"attribute_count": len(ont.attributes),
|
||||
"attributes": list(ont.attributes),
|
||||
"aliases": dict(ont.aliases),
|
||||
"is_builtin": is_builtin,
|
||||
"is_custom": is_custom,
|
||||
}
|
||||
|
||||
|
||||
def upsert_domain_definition(
|
||||
session,
|
||||
domain: str,
|
||||
payload: DomainDefinitionUpdateRequest,
|
||||
) -> models.DomainDefinition:
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
row = session.scalar(
|
||||
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
||||
)
|
||||
if row is None:
|
||||
row = models.DomainDefinition(domain=normalized_domain)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
row.description = payload.description.strip() if payload.description else None
|
||||
row.entity_types = clean_string_list(payload.entity_types)
|
||||
row.predicates = clean_string_list(payload.predicates)
|
||||
row.attributes = clean_string_list(payload.attributes)
|
||||
row.aliases = clean_aliases(payload.aliases)
|
||||
row.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
|
||||
row.updated_at = models.utcnow()
|
||||
return row
|
||||
|
||||
|
||||
def ensure_domain_definition(session, domain: str) -> models.DomainDefinition:
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
row = session.scalar(
|
||||
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
||||
)
|
||||
if row is not None:
|
||||
return row
|
||||
|
||||
base = DOMAIN_ONTOLOGIES.get(normalized_domain)
|
||||
row = models.DomainDefinition(
|
||||
domain=normalized_domain,
|
||||
entity_types=list(base.entity_types) if base else [],
|
||||
predicates=list(base.predicates) if base else [],
|
||||
attributes=list(base.attributes) if base else [],
|
||||
aliases=dict(base.aliases) if base else {},
|
||||
is_builtin_override=normalized_domain in DOMAIN_ONTOLOGIES,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def ontology_for_domain_from_db(session, domain: str):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
row = session.scalar(
|
||||
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
||||
)
|
||||
if row is not None:
|
||||
return domain_row_to_ontology(row)
|
||||
return ontology_for_domain(normalized_domain)
|
||||
|
||||
|
||||
def clean_seed_urls(values: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
url = value.strip()
|
||||
if not url or url in seen:
|
||||
continue
|
||||
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("file://")):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported URL: {url}")
|
||||
cleaned.append(url)
|
||||
seen.add(url)
|
||||
if not cleaned:
|
||||
raise HTTPException(status_code=400, detail="At least one reference URL is required.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def domain_discovery_job_payload(job: models.DomainDiscoveryJob) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"domain": job.domain,
|
||||
"status": job.status,
|
||||
"seed_urls": list(job.seed_urls or []),
|
||||
"max_pages": job.max_pages,
|
||||
"max_depth": job.max_depth,
|
||||
"same_domain_only": job.same_domain_only,
|
||||
"fetcher": job.fetcher,
|
||||
"respect_robots_txt": job.respect_robots_txt,
|
||||
"error": job.error,
|
||||
"progress": job.progress or {},
|
||||
"result_summary": job.result_summary or {},
|
||||
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||
"started_at": job.started_at.isoformat() if job.started_at else None,
|
||||
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
||||
}
|
||||
|
||||
|
||||
def domain_reference_source_payload(row: models.DomainReferenceSource) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"domain": row.domain,
|
||||
"url": row.url,
|
||||
"label": row.label,
|
||||
"status": row.status,
|
||||
"last_crawled_at": row.last_crawled_at.isoformat() if row.last_crawled_at else None,
|
||||
"metadata": row.metadata_json or {},
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def already_crawled_reference_urls(session, domain: str, seed_urls: list[str]) -> set[str]:
|
||||
if not seed_urls:
|
||||
return set()
|
||||
rows = session.scalars(
|
||||
select(models.DomainReferenceSource).where(
|
||||
models.DomainReferenceSource.domain == domain,
|
||||
models.DomainReferenceSource.url.in_(seed_urls),
|
||||
models.DomainReferenceSource.status == "active",
|
||||
models.DomainReferenceSource.last_crawled_at.is_not(None),
|
||||
)
|
||||
).all()
|
||||
return {row.url for row in rows}
|
||||
|
||||
|
||||
def domain_candidate_payload(row: models.DomainSchemaCandidate, evidence_rows: list[models.DomainCandidateEvidence]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"domain": row.domain,
|
||||
"job_id": row.job_id,
|
||||
"candidate_type": row.candidate_type,
|
||||
"name": row.name,
|
||||
"description": row.description,
|
||||
"confidence": row.confidence,
|
||||
"occurrence_count": row.occurrence_count,
|
||||
"status": row.status,
|
||||
"metadata": row.metadata_json or {},
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"evidence": [
|
||||
{
|
||||
"id": evidence.id,
|
||||
"job_id": evidence.job_id,
|
||||
"url": evidence.url,
|
||||
"title": evidence.title,
|
||||
"snippet": evidence.snippet,
|
||||
"metadata": evidence.metadata_json or {},
|
||||
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
|
||||
}
|
||||
for evidence in evidence_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def run_domain_discovery_job(database_url: str, job_id: int) -> None:
|
||||
try:
|
||||
with session_scope(database_url) as session:
|
||||
DomainDiscoveryService(session).run_job(job_id)
|
||||
except Exception as exc:
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.DomainDiscoveryJob, job_id)
|
||||
if job is not None:
|
||||
job.status = "failed"
|
||||
job.error = str(exc)
|
||||
job.finished_at = models.utcnow()
|
||||
progress = dict(job.progress or {})
|
||||
progress["errors"] = [*progress.get("errors", []), str(exc)]
|
||||
job.progress = progress
|
||||
|
||||
|
||||
def recover_interrupted_domain_discovery_jobs(database_url: str) -> None:
|
||||
"""Mark domain discovery jobs that cannot survive a server restart."""
|
||||
with session_scope(database_url) as session:
|
||||
rows = session.scalars(
|
||||
select(models.DomainDiscoveryJob).where(
|
||||
models.DomainDiscoveryJob.status.in_(
|
||||
["pending", "running", "cancel_requested"]
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for job in rows:
|
||||
progress = dict(job.progress or {})
|
||||
errors = list(progress.get("errors") or [])
|
||||
if job.status == "cancel_requested":
|
||||
job.status = "canceled"
|
||||
progress["completion_reason"] = "canceled"
|
||||
else:
|
||||
job.status = "failed"
|
||||
job.error = "Server restarted before this background analysis finished."
|
||||
progress["completion_reason"] = "interrupted"
|
||||
errors.append(job.error)
|
||||
progress["errors"] = errors[-10:]
|
||||
job.progress = progress
|
||||
job.finished_at = models.utcnow()
|
||||
|
||||
|
||||
def project_config_from_project_row(session, project: models.Project) -> ProjectConfig:
|
||||
config_dict = dict(project.config or {})
|
||||
if not config_dict:
|
||||
@@ -195,6 +479,32 @@ def project_config_to_dict(config: ProjectConfig) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def delete_project_data(session, project_id: int, include_project: bool = False) -> dict[str, int]:
|
||||
repo = KnowledgeRepository(session)
|
||||
deleted = repo.reset_project_runtime_data(project_id)
|
||||
for table_model in [
|
||||
models.OntologyProposal,
|
||||
models.OntologyTriple,
|
||||
models.OntologyRelationType,
|
||||
models.OntologyEntityType,
|
||||
models.Source,
|
||||
]:
|
||||
count = (
|
||||
session.query(table_model)
|
||||
.filter(table_model.project_id == project_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
deleted[table_model.__tablename__] = deleted.get(table_model.__tablename__, 0) + int(count or 0)
|
||||
if include_project:
|
||||
project_count = (
|
||||
session.query(models.Project)
|
||||
.filter(models.Project.id == project_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
deleted[models.Project.__tablename__] = int(project_count or 0)
|
||||
return deleted
|
||||
|
||||
|
||||
class CreateEntityRequest(BaseModel):
|
||||
entity_type: str
|
||||
name: str
|
||||
@@ -485,6 +795,8 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
|
||||
|
||||
|
||||
def register_routes(app, database_url: str) -> None:
|
||||
recover_interrupted_domain_discovery_jobs(database_url)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
@@ -520,16 +832,298 @@ def register_routes(app, database_url: str) -> None:
|
||||
|
||||
@app.get("/domains")
|
||||
def list_domains():
|
||||
"""Available pre-defined ontology domains for project creation."""
|
||||
return [
|
||||
{
|
||||
"domain": ont.domain,
|
||||
"entity_types": list(ont.entity_types),
|
||||
"predicates": list(ont.predicates),
|
||||
"attribute_count": len(ont.attributes),
|
||||
"""Available ontology domains for project creation."""
|
||||
with session_scope(database_url) as session:
|
||||
rows = session.scalars(select(models.DomainDefinition)).all()
|
||||
custom_by_domain = {row.domain: row for row in rows}
|
||||
payloads = []
|
||||
for domain, built_in in DOMAIN_ONTOLOGIES.items():
|
||||
row = custom_by_domain.pop(domain, None)
|
||||
if row is not None:
|
||||
payloads.append(
|
||||
domain_summary_payload(
|
||||
domain_row_to_ontology(row),
|
||||
is_builtin=True,
|
||||
is_custom=True,
|
||||
description=row.description,
|
||||
)
|
||||
)
|
||||
else:
|
||||
payloads.append(
|
||||
domain_summary_payload(
|
||||
built_in,
|
||||
is_builtin=True,
|
||||
is_custom=False,
|
||||
)
|
||||
)
|
||||
for row in sorted(custom_by_domain.values(), key=lambda item: item.domain):
|
||||
payloads.append(
|
||||
domain_summary_payload(
|
||||
domain_row_to_ontology(row),
|
||||
is_builtin=False,
|
||||
is_custom=True,
|
||||
description=row.description,
|
||||
)
|
||||
)
|
||||
return payloads
|
||||
|
||||
@app.post("/domains")
|
||||
def create_domain(request: DomainDefinitionRequest):
|
||||
domain = normalize_domain_name(request.domain)
|
||||
with session_scope(database_url) as session:
|
||||
existing = session.scalar(
|
||||
select(models.DomainDefinition).where(models.DomainDefinition.domain == domain)
|
||||
)
|
||||
if existing is not None or domain in DOMAIN_ONTOLOGIES:
|
||||
raise HTTPException(status_code=409, detail=f"Domain '{domain}' already exists.")
|
||||
row = upsert_domain_definition(
|
||||
session,
|
||||
domain,
|
||||
DomainDefinitionUpdateRequest(
|
||||
description=request.description,
|
||||
entity_types=request.entity_types,
|
||||
predicates=request.predicates,
|
||||
attributes=request.attributes,
|
||||
aliases=request.aliases,
|
||||
),
|
||||
)
|
||||
return domain_summary_payload(
|
||||
domain_row_to_ontology(row),
|
||||
is_builtin=domain in DOMAIN_ONTOLOGIES,
|
||||
is_custom=True,
|
||||
description=row.description,
|
||||
)
|
||||
|
||||
@app.put("/domains/{domain}")
|
||||
def update_domain(domain: str, request: DomainDefinitionUpdateRequest):
|
||||
with session_scope(database_url) as session:
|
||||
row = upsert_domain_definition(session, domain, request)
|
||||
return domain_summary_payload(
|
||||
domain_row_to_ontology(row),
|
||||
is_builtin=row.domain in DOMAIN_ONTOLOGIES,
|
||||
is_custom=True,
|
||||
description=row.description,
|
||||
)
|
||||
|
||||
@app.get("/domains/{domain}/discovery/jobs")
|
||||
def list_domain_discovery_jobs(domain: str, limit: int = 20):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
with session_scope(database_url) as session:
|
||||
rows = session.scalars(
|
||||
select(models.DomainDiscoveryJob)
|
||||
.where(models.DomainDiscoveryJob.domain == normalized_domain)
|
||||
.order_by(models.DomainDiscoveryJob.created_at.desc())
|
||||
.limit(max(min(limit, 100), 1))
|
||||
).all()
|
||||
return [domain_discovery_job_payload(row) for row in rows]
|
||||
|
||||
@app.get("/domains/{domain}/reference-sources")
|
||||
def list_domain_reference_sources(domain: str):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
with session_scope(database_url) as session:
|
||||
rows = session.scalars(
|
||||
select(models.DomainReferenceSource)
|
||||
.where(models.DomainReferenceSource.domain == normalized_domain)
|
||||
.order_by(models.DomainReferenceSource.updated_at.desc())
|
||||
).all()
|
||||
return [domain_reference_source_payload(row) for row in rows]
|
||||
|
||||
@app.post("/domains/{domain}/discovery/jobs")
|
||||
def create_domain_discovery_job(
|
||||
domain: str,
|
||||
request: DomainDiscoveryRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
seed_urls = clean_seed_urls(request.seed_urls)
|
||||
with session_scope(database_url) as session:
|
||||
ensure_domain_definition(session, normalized_domain)
|
||||
skipped_seed_urls: list[str] = []
|
||||
if not request.force_recrawl:
|
||||
skipped = already_crawled_reference_urls(session, normalized_domain, seed_urls)
|
||||
skipped_seed_urls = [url for url in seed_urls if url in skipped]
|
||||
seed_urls = [url for url in seed_urls if url not in skipped]
|
||||
|
||||
if not seed_urls:
|
||||
job = models.DomainDiscoveryJob(
|
||||
domain=normalized_domain,
|
||||
status="completed",
|
||||
seed_urls=[],
|
||||
max_pages=max(min(request.max_pages, 200), 1),
|
||||
max_depth=max(min(request.max_depth, 5), 0),
|
||||
same_domain_only=request.same_domain_only,
|
||||
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
|
||||
respect_robots_txt=request.respect_robots_txt,
|
||||
progress={
|
||||
"visited_count": 0,
|
||||
"queued_count": 0,
|
||||
"candidate_count": 0,
|
||||
"observation_count": 0,
|
||||
"max_pages": max(min(request.max_pages, 200), 1),
|
||||
"skipped_seed_urls": skipped_seed_urls,
|
||||
"errors": [],
|
||||
"pages": [],
|
||||
},
|
||||
result_summary={
|
||||
"visited_count": 0,
|
||||
"candidate_count": 0,
|
||||
"skipped_seed_urls": skipped_seed_urls,
|
||||
},
|
||||
finished_at=models.utcnow(),
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
return domain_discovery_job_payload(job)
|
||||
|
||||
job = models.DomainDiscoveryJob(
|
||||
domain=normalized_domain,
|
||||
status="pending",
|
||||
seed_urls=seed_urls,
|
||||
max_pages=max(min(request.max_pages, 200), 1),
|
||||
max_depth=max(min(request.max_depth, 5), 0),
|
||||
same_domain_only=request.same_domain_only,
|
||||
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
|
||||
respect_robots_txt=request.respect_robots_txt,
|
||||
progress={
|
||||
"visited_count": 0,
|
||||
"queued_count": len(seed_urls),
|
||||
"candidate_count": 0,
|
||||
"observation_count": 0,
|
||||
"max_pages": max(min(request.max_pages, 200), 1),
|
||||
"skipped_seed_urls": skipped_seed_urls,
|
||||
"errors": [],
|
||||
"pages": [],
|
||||
},
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
response = domain_discovery_job_payload(job)
|
||||
background_tasks.add_task(run_domain_discovery_job, database_url, response["job_id"])
|
||||
return response
|
||||
|
||||
@app.get("/domains/{domain}/discovery/jobs/{job_id}")
|
||||
def domain_discovery_job(domain: str, job_id: int):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.DomainDiscoveryJob, job_id)
|
||||
if job is None or job.domain != normalized_domain:
|
||||
raise HTTPException(status_code=404, detail="domain discovery job not found")
|
||||
return domain_discovery_job_payload(job)
|
||||
|
||||
@app.post("/domains/{domain}/discovery/jobs/{job_id}/cancel")
|
||||
def cancel_domain_discovery_job(domain: str, job_id: int):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.DomainDiscoveryJob, job_id)
|
||||
if job is None or job.domain != normalized_domain:
|
||||
raise HTTPException(status_code=404, detail="domain discovery job not found")
|
||||
if job.status in {"completed", "failed", "canceled"}:
|
||||
return domain_discovery_job_payload(job)
|
||||
job.status = "cancel_requested"
|
||||
job.error = "cancel requested by user"
|
||||
return domain_discovery_job_payload(job)
|
||||
|
||||
@app.get("/domains/{domain}/candidates")
|
||||
def list_domain_candidates(domain: str, status: str | None = None, limit: int = 200):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
with session_scope(database_url) as session:
|
||||
query = select(models.DomainSchemaCandidate).where(
|
||||
models.DomainSchemaCandidate.domain == normalized_domain
|
||||
)
|
||||
if status:
|
||||
query = query.where(models.DomainSchemaCandidate.status == status)
|
||||
rows = session.scalars(
|
||||
query.order_by(
|
||||
models.DomainSchemaCandidate.status,
|
||||
models.DomainSchemaCandidate.candidate_type,
|
||||
models.DomainSchemaCandidate.confidence.desc(),
|
||||
models.DomainSchemaCandidate.name,
|
||||
).limit(max(min(limit, 500), 1))
|
||||
).all()
|
||||
payload = []
|
||||
for row in rows:
|
||||
evidence = session.scalars(
|
||||
select(models.DomainCandidateEvidence)
|
||||
.where(models.DomainCandidateEvidence.candidate_id == row.id)
|
||||
.order_by(models.DomainCandidateEvidence.created_at.desc())
|
||||
.limit(3)
|
||||
).all()
|
||||
payload.append(domain_candidate_payload(row, evidence))
|
||||
return payload
|
||||
|
||||
@app.post("/domains/{domain}/candidates/apply")
|
||||
def apply_domain_candidates(domain: str, request: DomainCandidateApplyRequest):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
if not request.candidate_ids:
|
||||
raise HTTPException(status_code=400, detail="No candidates selected.")
|
||||
with session_scope(database_url) as session:
|
||||
definition = ensure_domain_definition(session, normalized_domain)
|
||||
rows = session.scalars(
|
||||
select(models.DomainSchemaCandidate).where(
|
||||
models.DomainSchemaCandidate.domain == normalized_domain,
|
||||
models.DomainSchemaCandidate.id.in_(request.candidate_ids),
|
||||
)
|
||||
).all()
|
||||
entity_types = set(definition.entity_types or [])
|
||||
predicates = set(definition.predicates or [])
|
||||
attributes = set(definition.attributes or [])
|
||||
aliases = dict(definition.aliases or {})
|
||||
applied = {"entity_type": 0, "predicate": 0, "attribute": 0, "alias": 0}
|
||||
for row in rows:
|
||||
if row.candidate_type == "entity_type":
|
||||
entity_types.add(row.name)
|
||||
applied["entity_type"] += 1
|
||||
elif row.candidate_type == "predicate":
|
||||
predicates.add(row.name)
|
||||
applied["predicate"] += 1
|
||||
elif row.candidate_type == "attribute":
|
||||
attributes.add(row.name)
|
||||
applied["attribute"] += 1
|
||||
elif row.candidate_type == "alias":
|
||||
metadata = dict(row.metadata_json or {})
|
||||
targets = metadata.get("targets") or []
|
||||
target = metadata.get("target") or (targets[0] if targets else None)
|
||||
if target:
|
||||
aliases[row.name] = str(target)
|
||||
applied["alias"] += 1
|
||||
row.status = "approved"
|
||||
row.updated_at = models.utcnow()
|
||||
definition.entity_types = sorted(entity_types)
|
||||
definition.predicates = sorted(predicates)
|
||||
definition.attributes = sorted(attributes)
|
||||
definition.aliases = aliases
|
||||
definition.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
|
||||
definition.updated_at = models.utcnow()
|
||||
return {
|
||||
"ok": True,
|
||||
"domain": normalized_domain,
|
||||
"applied": applied,
|
||||
"definition": domain_summary_payload(
|
||||
domain_row_to_ontology(definition),
|
||||
is_builtin=normalized_domain in DOMAIN_ONTOLOGIES,
|
||||
is_custom=True,
|
||||
description=definition.description,
|
||||
),
|
||||
}
|
||||
for ont in DOMAIN_ONTOLOGIES.values()
|
||||
]
|
||||
|
||||
@app.post("/domains/{domain}/candidates/{candidate_id}/status")
|
||||
def update_domain_candidate_status(domain: str, candidate_id: int, request: DomainCandidateStatusRequest):
|
||||
normalized_domain = normalize_domain_name(domain)
|
||||
if request.status not in {"pending_review", "approved", "rejected"}:
|
||||
raise HTTPException(status_code=400, detail="Unsupported candidate status.")
|
||||
with session_scope(database_url) as session:
|
||||
row = session.get(models.DomainSchemaCandidate, candidate_id)
|
||||
if row is None or row.domain != normalized_domain:
|
||||
raise HTTPException(status_code=404, detail="candidate not found")
|
||||
row.status = request.status
|
||||
row.updated_at = models.utcnow()
|
||||
evidence = session.scalars(
|
||||
select(models.DomainCandidateEvidence)
|
||||
.where(models.DomainCandidateEvidence.candidate_id == row.id)
|
||||
.order_by(models.DomainCandidateEvidence.created_at.desc())
|
||||
.limit(3)
|
||||
).all()
|
||||
return domain_candidate_payload(row, evidence)
|
||||
|
||||
@app.post("/projects/reset")
|
||||
def reset_project(request: ResetProjectRequest):
|
||||
@@ -568,6 +1162,35 @@ def register_routes(app, database_url: str) -> None:
|
||||
"deleted": deleted,
|
||||
}
|
||||
|
||||
@app.post("/projects/{project_name}/reset")
|
||||
def reset_project_by_name(project_name: str):
|
||||
with session_scope(database_url) as session:
|
||||
repo = KnowledgeRepository(session)
|
||||
project = repo.get_project(project_name)
|
||||
config = project_config_from_project_row(session, project)
|
||||
deleted = repo.reset_project_runtime_data(project.id)
|
||||
project = repo.upsert_project(config)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": project.name,
|
||||
"domain": project.domain,
|
||||
"reset": True,
|
||||
"deleted": deleted,
|
||||
}
|
||||
|
||||
@app.delete("/projects/{project_name}")
|
||||
def delete_project(project_name: str):
|
||||
with session_scope(database_url) as session:
|
||||
project = KnowledgeRepository(session).get_project(project_name)
|
||||
domain = project.domain
|
||||
deleted = delete_project_data(session, project.id, include_project=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": project_name,
|
||||
"domain": domain,
|
||||
"deleted": deleted,
|
||||
}
|
||||
|
||||
@app.get("/projects/{project_name}")
|
||||
def project_detail(project_name: str):
|
||||
with session_scope(database_url) as session:
|
||||
@@ -630,7 +1253,8 @@ def register_routes(app, database_url: str) -> None:
|
||||
|
||||
@app.get("/ontology/{domain}")
|
||||
def ontology(domain: str):
|
||||
return ontology_to_dict(ontology_for_domain(domain))
|
||||
with session_scope(database_url) as session:
|
||||
return ontology_to_dict(ontology_for_domain_from_db(session, domain))
|
||||
|
||||
@app.get("/projects/{project_name}/ontology/registry")
|
||||
def ontology_registry(project_name: str):
|
||||
|
||||
Reference in New Issue
Block a user