Initial SFERA platform baseline
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# sfera-operations-core
|
||||
|
||||
Operations primitives for jobs, observability, reports, marketplace inventory, and licensing.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "sfera-operations-core"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"sfera-integration-topology",
|
||||
"sfera-sir",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
from integration_topology import IntegrationKind, build_integration_topology
|
||||
from sir import EdgeKind, NodeKind, SirSnapshot
|
||||
|
||||
|
||||
class OperationJobStatus(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELED = "CANCELED"
|
||||
|
||||
|
||||
class OperationJob(BaseModel):
|
||||
job_id: str
|
||||
kind: str
|
||||
status: OperationJobStatus = OperationJobStatus.QUEUED
|
||||
payload: dict = Field(default_factory=dict)
|
||||
result: dict = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class MetricSample(BaseModel):
|
||||
metric_id: str
|
||||
name: str
|
||||
value: float
|
||||
unit: str | None = None
|
||||
observed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AiUsageRecord(BaseModel):
|
||||
usage_id: str
|
||||
project_id: str
|
||||
user_id: str
|
||||
model: str
|
||||
operation: str
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
currency: str = "USD"
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
attributes: dict = Field(default_factory=dict)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
return self.prompt_tokens + self.completion_tokens
|
||||
|
||||
|
||||
class AiUsageSummary(BaseModel):
|
||||
project_id: str | None = None
|
||||
user_id: str | None = None
|
||||
model: str | None = None
|
||||
operation: str | None = None
|
||||
request_count: int = 0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
class ProjectReport(BaseModel):
|
||||
project_id: str
|
||||
snapshot_id: str
|
||||
node_count: int
|
||||
edge_count: int
|
||||
procedure_count: int
|
||||
query_count: int
|
||||
write_count: int
|
||||
role_count: int = 0
|
||||
access_grant_count: int = 0
|
||||
attribute_count: int = 0
|
||||
object_attribute_count: int = 0
|
||||
tabular_section_count: int = 0
|
||||
tabular_section_column_count: int = 0
|
||||
empty_tabular_section_count: int = 0
|
||||
empty_tabular_sections: list[str] = Field(default_factory=list)
|
||||
unsecured_object_count: int = 0
|
||||
unsecured_objects: list[str] = Field(default_factory=list)
|
||||
integration_count: int = 0
|
||||
outbound_integration_count: int = 0
|
||||
integration_kinds: dict[str, int] = Field(default_factory=dict)
|
||||
diagnostic_count: int
|
||||
generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class MarketplacePackage(BaseModel):
|
||||
package_id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str = ""
|
||||
enabled: bool = True
|
||||
attributes: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LicenseState(BaseModel):
|
||||
license_id: str = "community"
|
||||
plan: str = "community"
|
||||
valid: bool = True
|
||||
seats: int | None = None
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class InMemoryOperationsStore:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: dict[str, OperationJob] = {}
|
||||
self.metrics: list[MetricSample] = []
|
||||
self.ai_usage: dict[str, AiUsageRecord] = {}
|
||||
self.marketplace: dict[str, MarketplacePackage] = {}
|
||||
self.license_state = LicenseState()
|
||||
|
||||
def enqueue(self, job: OperationJob) -> OperationJob:
|
||||
self.jobs[job.job_id] = job
|
||||
return job
|
||||
|
||||
def update_job(
|
||||
self,
|
||||
job_id: str,
|
||||
status: OperationJobStatus,
|
||||
*,
|
||||
result: dict | None = None,
|
||||
error: str | None = None,
|
||||
) -> OperationJob:
|
||||
job = self.jobs[job_id]
|
||||
job.status = status
|
||||
job.result = result or job.result
|
||||
job.error = error
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
return job
|
||||
|
||||
def list_jobs(self, status: OperationJobStatus | None = None) -> list[OperationJob]:
|
||||
jobs = list(self.jobs.values())
|
||||
if status is not None:
|
||||
jobs = [job for job in jobs if job.status == status]
|
||||
return sorted(jobs, key=lambda job: job.created_at, reverse=True)
|
||||
|
||||
def record_metric(self, metric: MetricSample) -> MetricSample:
|
||||
self.metrics.append(metric)
|
||||
return metric
|
||||
|
||||
def list_metrics(self, name: str | None = None) -> list[MetricSample]:
|
||||
samples = self.metrics
|
||||
if name is not None:
|
||||
samples = [sample for sample in samples if sample.name == name]
|
||||
return sorted(samples, key=lambda sample: sample.observed_at, reverse=True)
|
||||
|
||||
def record_ai_usage(self, usage: AiUsageRecord) -> AiUsageRecord:
|
||||
self.ai_usage[usage.usage_id] = usage
|
||||
return usage
|
||||
|
||||
def list_ai_usage(
|
||||
self,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[AiUsageRecord]:
|
||||
records = list(self.ai_usage.values())
|
||||
if project_id is not None:
|
||||
records = [record for record in records if record.project_id == project_id]
|
||||
if user_id is not None:
|
||||
records = [record for record in records if record.user_id == user_id]
|
||||
if model is not None:
|
||||
records = [record for record in records if record.model == model]
|
||||
return sorted(records, key=lambda record: record.created_at, reverse=True)
|
||||
|
||||
def summarize_ai_usage(
|
||||
self,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
model: str | None = None,
|
||||
operation: str | None = None,
|
||||
) -> AiUsageSummary:
|
||||
records = self.list_ai_usage(project_id=project_id, user_id=user_id, model=model)
|
||||
if operation is not None:
|
||||
records = [record for record in records if record.operation == operation]
|
||||
prompt_tokens = sum(record.prompt_tokens for record in records)
|
||||
completion_tokens = sum(record.completion_tokens for record in records)
|
||||
return AiUsageSummary(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
operation=operation,
|
||||
request_count=len(records),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
cost=sum(record.cost for record in records),
|
||||
)
|
||||
|
||||
def upsert_marketplace_package(self, package: MarketplacePackage) -> MarketplacePackage:
|
||||
self.marketplace[package.package_id] = package
|
||||
return package
|
||||
|
||||
def list_marketplace_packages(self, enabled_only: bool = False) -> list[MarketplacePackage]:
|
||||
packages = list(self.marketplace.values())
|
||||
if enabled_only:
|
||||
packages = [package for package in packages if package.enabled]
|
||||
return sorted(packages, key=lambda package: package.name)
|
||||
|
||||
def set_license(self, license_state: LicenseState) -> LicenseState:
|
||||
self.license_state = license_state
|
||||
return license_state
|
||||
|
||||
|
||||
def build_project_report(snapshot: SirSnapshot) -> ProjectReport:
|
||||
integration_topology = build_integration_topology(snapshot)
|
||||
integration_kinds = {
|
||||
kind.value: len(integration_topology.by_kind(kind))
|
||||
for kind in IntegrationKind
|
||||
if integration_topology.by_kind(kind)
|
||||
}
|
||||
access_target_kinds = {
|
||||
NodeKind.CATALOG,
|
||||
NodeKind.DOCUMENT,
|
||||
NodeKind.REGISTER,
|
||||
NodeKind.COMMON_MODULE,
|
||||
NodeKind.EXCHANGE_PLAN,
|
||||
NodeKind.SCHEDULED_JOB,
|
||||
NodeKind.BUSINESS_PROCESS,
|
||||
NodeKind.TASK,
|
||||
}
|
||||
secured_lineages = {
|
||||
edge.target_lineage
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.GRANTS_ACCESS
|
||||
}
|
||||
unsecured_objects = sorted(
|
||||
node.qualified_name
|
||||
for node in snapshot.nodes
|
||||
if node.kind in access_target_kinds and node.lineage_id not in secured_lineages
|
||||
)
|
||||
nodes = {node.lineage_id: node for node in snapshot.nodes}
|
||||
object_attribute_edges = [
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.HAS_ATTRIBUTE
|
||||
and (source := nodes.get(edge.source_lineage)) is not None
|
||||
and source.kind in access_target_kinds
|
||||
]
|
||||
tabular_section_lineages = {
|
||||
node.lineage_id
|
||||
for node in snapshot.nodes
|
||||
if node.kind == NodeKind.TABULAR_SECTION
|
||||
}
|
||||
tabular_section_column_edges = [
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.HAS_ATTRIBUTE and edge.source_lineage in tabular_section_lineages
|
||||
]
|
||||
tabular_sections_with_columns = {edge.source_lineage for edge in tabular_section_column_edges}
|
||||
empty_tabular_sections = sorted(
|
||||
nodes[lineage].qualified_name
|
||||
for lineage in tabular_section_lineages
|
||||
if lineage not in tabular_sections_with_columns
|
||||
)
|
||||
return ProjectReport(
|
||||
project_id=snapshot.project_id,
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
node_count=len(snapshot.nodes),
|
||||
edge_count=len(snapshot.edges),
|
||||
procedure_count=sum(
|
||||
1 for node in snapshot.nodes if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}
|
||||
),
|
||||
query_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.QUERY),
|
||||
write_count=sum(1 for edge in snapshot.edges if edge.kind == EdgeKind.WRITES),
|
||||
role_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.ROLE),
|
||||
access_grant_count=sum(1 for edge in snapshot.edges if edge.kind == EdgeKind.GRANTS_ACCESS),
|
||||
attribute_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE),
|
||||
object_attribute_count=len(object_attribute_edges),
|
||||
tabular_section_count=len(tabular_section_lineages),
|
||||
tabular_section_column_count=len(tabular_section_column_edges),
|
||||
empty_tabular_section_count=len(empty_tabular_sections),
|
||||
empty_tabular_sections=empty_tabular_sections,
|
||||
unsecured_object_count=len(unsecured_objects),
|
||||
unsecured_objects=unsecured_objects,
|
||||
integration_count=len(integration_topology.endpoints),
|
||||
outbound_integration_count=sum(
|
||||
1 for endpoint in integration_topology.endpoints if endpoint.direction == "OUTBOUND"
|
||||
),
|
||||
integration_kinds=integration_kinds,
|
||||
diagnostic_count=len(snapshot.diagnostics),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AiUsageRecord",
|
||||
"AiUsageSummary",
|
||||
"InMemoryOperationsStore",
|
||||
"LicenseState",
|
||||
"MarketplacePackage",
|
||||
"MetricSample",
|
||||
"OperationJob",
|
||||
"OperationJobStatus",
|
||||
"ProjectReport",
|
||||
"build_project_report",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
from pathlib import Path
|
||||
|
||||
from operations_core import (
|
||||
AiUsageRecord,
|
||||
InMemoryOperationsStore,
|
||||
MarketplacePackage,
|
||||
OperationJob,
|
||||
OperationJobStatus,
|
||||
build_project_report,
|
||||
)
|
||||
from semantic_kernel import index_project
|
||||
|
||||
|
||||
def test_operations_store_jobs_and_marketplace():
|
||||
store = InMemoryOperationsStore()
|
||||
job = store.enqueue(OperationJob(job_id="job.1", kind="INDEX_PROJECT"))
|
||||
store.update_job(job.job_id, OperationJobStatus.SUCCEEDED, result={"ok": True})
|
||||
store.upsert_marketplace_package(
|
||||
MarketplacePackage(package_id="pack.1", name="BSP Knowledge", version="1.0.0")
|
||||
)
|
||||
|
||||
assert store.list_jobs()[0].status == OperationJobStatus.SUCCEEDED
|
||||
assert store.list_marketplace_packages()[0].name == "BSP Knowledge"
|
||||
|
||||
|
||||
def test_operations_store_ai_usage_summary():
|
||||
store = InMemoryOperationsStore()
|
||||
store.record_ai_usage(
|
||||
AiUsageRecord(
|
||||
usage_id="usage.1",
|
||||
project_id="demo",
|
||||
user_id="user.1",
|
||||
model="gpt-test",
|
||||
operation="review",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=40,
|
||||
cost=0.01,
|
||||
)
|
||||
)
|
||||
store.record_ai_usage(
|
||||
AiUsageRecord(
|
||||
usage_id="usage.2",
|
||||
project_id="demo",
|
||||
user_id="user.1",
|
||||
model="gpt-test",
|
||||
operation="impact",
|
||||
prompt_tokens=50,
|
||||
completion_tokens=10,
|
||||
cost=0.005,
|
||||
)
|
||||
)
|
||||
|
||||
summary = store.summarize_ai_usage(project_id="demo", user_id="user.1", model="gpt-test")
|
||||
|
||||
assert summary.request_count == 2
|
||||
assert summary.total_tokens == 200
|
||||
assert summary.cost == 0.015
|
||||
|
||||
|
||||
def test_build_project_report_counts_snapshot_parts(tmp_path: Path):
|
||||
module = tmp_path / "demo_module.bsl"
|
||||
module.write_text(
|
||||
"""
|
||||
Процедура Проведение()
|
||||
Движения.ОстаткиТоваров.Записать();
|
||||
КонецПроцедуры
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = index_project(tmp_path, project_id="demo")
|
||||
|
||||
report = build_project_report(snapshot)
|
||||
|
||||
assert report.procedure_count == 1
|
||||
assert report.write_count == 1
|
||||
|
||||
|
||||
def test_build_project_report_counts_1c_role_access(tmp_path: Path):
|
||||
xml = tmp_path / "metadata.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<Configuration>
|
||||
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
|
||||
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура" />
|
||||
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
|
||||
<Right object="Документ.ЗаказПокупателя" read="true" write="true" />
|
||||
</Role>
|
||||
</Configuration>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = index_project(tmp_path, project_id="security-report")
|
||||
|
||||
report = build_project_report(snapshot)
|
||||
|
||||
assert report.role_count == 1
|
||||
assert report.access_grant_count == 1
|
||||
assert report.unsecured_object_count == 1
|
||||
assert report.unsecured_objects == ["Справочник.Номенклатура"]
|
||||
|
||||
|
||||
def test_build_project_report_counts_1c_schema(tmp_path: Path):
|
||||
xml = tmp_path / "metadata.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<Configuration>
|
||||
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
|
||||
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
|
||||
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
|
||||
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
|
||||
</TabularSection>
|
||||
<TabularSection name="Услуги" qualifiedName="Документ.ЗаказПокупателя.Услуги" />
|
||||
</Document>
|
||||
</Configuration>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = index_project(tmp_path, project_id="schema-report")
|
||||
|
||||
report = build_project_report(snapshot)
|
||||
|
||||
assert report.attribute_count == 2
|
||||
assert report.object_attribute_count == 1
|
||||
assert report.tabular_section_count == 2
|
||||
assert report.tabular_section_column_count == 1
|
||||
assert report.empty_tabular_section_count == 1
|
||||
assert report.empty_tabular_sections == ["Документ.ЗаказПокупателя.Услуги"]
|
||||
|
||||
|
||||
def test_build_project_report_counts_integrations(tmp_path: Path):
|
||||
module = tmp_path / "integration.bsl"
|
||||
module.write_text(
|
||||
"""
|
||||
Процедура Отправить()
|
||||
Адрес = "https://api.example.local/orders";
|
||||
Объект = Новый COMОбъект("V83.Application");
|
||||
КонецПроцедуры
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = index_project(tmp_path, project_id="integration-report")
|
||||
|
||||
report = build_project_report(snapshot)
|
||||
|
||||
assert report.integration_count == 2
|
||||
assert report.outbound_integration_count == 2
|
||||
assert report.integration_kinds["HTTP_SERVICE"] == 1
|
||||
assert report.integration_kinds["COM_CONNECTOR"] == 1
|
||||
Reference in New Issue
Block a user