From 78895338d37602a1a161cdc3ef8d26f74885b083 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 13:14:20 +0800 Subject: [PATCH] Initial commit with translated description --- SKILL.md | 232 +++++++++++++++++ _meta.json | 6 + references/queries.md | 211 +++++++++++++++ references/schema.md | 322 +++++++++++++++++++++++ scripts/ontology.py | 580 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1351 insertions(+) create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 references/queries.md create mode 100644 references/schema.md create mode 100644 scripts/ontology.py diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4faaf25 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,232 @@ +--- +name: ontology +description: "用于结构化代理记忆和可组合技能的类型化知识图谱。" +--- + +# Ontology + +A typed vocabulary + constraint system for representing knowledge as a verifiable graph. + +## Core Concept + +Everything is an **entity** with a **type**, **properties**, and **relations** to other entities. Every mutation is validated against type constraints before committing. + +``` +Entity: { id, type, properties, relations, created, updated } +Relation: { from_id, relation_type, to_id, properties } +``` + +## When to Use + +| Trigger | Action | +|---------|--------| +| "Remember that..." | Create/update entity | +| "What do I know about X?" | Query graph | +| "Link X to Y" | Create relation | +| "Show all tasks for project Z" | Graph traversal | +| "What depends on X?" | Dependency query | +| Planning multi-step work | Model as graph transformations | +| Skill needs shared state | Read/write ontology objects | + +## Core Types + +```yaml +# Agents & People +Person: { name, email?, phone?, notes? } +Organization: { name, type?, members[] } + +# Work +Project: { name, status, goals[], owner? } +Task: { title, status, due?, priority?, assignee?, blockers[] } +Goal: { description, target_date?, metrics[] } + +# Time & Place +Event: { title, start, end?, location?, attendees[], recurrence? } +Location: { name, address?, coordinates? } + +# Information +Document: { title, path?, url?, summary? } +Message: { content, sender, recipients[], thread? } +Thread: { subject, participants[], messages[] } +Note: { content, tags[], refs[] } + +# Resources +Account: { service, username, credential_ref? } +Device: { name, type, identifiers[] } +Credential: { service, secret_ref } # Never store secrets directly + +# Meta +Action: { type, target, timestamp, outcome? } +Policy: { scope, rule, enforcement } +``` + +## Storage + +Default: `memory/ontology/graph.jsonl` + +```jsonl +{"op":"create","entity":{"id":"p_001","type":"Person","properties":{"name":"Alice"}}} +{"op":"create","entity":{"id":"proj_001","type":"Project","properties":{"name":"Website Redesign","status":"active"}}} +{"op":"relate","from":"proj_001","rel":"has_owner","to":"p_001"} +``` + +Query via scripts or direct file ops. For complex graphs, migrate to SQLite. + +### Append-Only Rule + +When working with existing ontology data or schema, **append/merge** changes instead of overwriting files. This preserves history and avoids clobbering prior definitions. + +## Workflows + +### Create Entity + +```bash +python3 scripts/ontology.py create --type Person --props '{"name":"Alice","email":"alice@example.com"}' +``` + +### Query + +```bash +python3 scripts/ontology.py query --type Task --where '{"status":"open"}' +python3 scripts/ontology.py get --id task_001 +python3 scripts/ontology.py related --id proj_001 --rel has_task +``` + +### Link Entities + +```bash +python3 scripts/ontology.py relate --from proj_001 --rel has_task --to task_001 +``` + +### Validate + +```bash +python3 scripts/ontology.py validate # Check all constraints +``` + +## Constraints + +Define in `memory/ontology/schema.yaml`: + +```yaml +types: + Task: + required: [title, status] + status_enum: [open, in_progress, blocked, done] + + Event: + required: [title, start] + validate: "end >= start if end exists" + + Credential: + required: [service, secret_ref] + forbidden_properties: [password, secret, token] # Force indirection + +relations: + has_owner: + from_types: [Project, Task] + to_types: [Person] + cardinality: many_to_one + + blocks: + from_types: [Task] + to_types: [Task] + acyclic: true # No circular dependencies +``` + +## Skill Contract + +Skills that use ontology should declare: + +```yaml +# In SKILL.md frontmatter or header +ontology: + reads: [Task, Project, Person] + writes: [Task, Action] + preconditions: + - "Task.assignee must exist" + postconditions: + - "Created Task has status=open" +``` + +## Planning as Graph Transformation + +Model multi-step plans as a sequence of graph operations: + +``` +Plan: "Schedule team meeting and create follow-up tasks" + +1. CREATE Event { title: "Team Sync", attendees: [p_001, p_002] } +2. RELATE Event -> has_project -> proj_001 +3. CREATE Task { title: "Prepare agenda", assignee: p_001 } +4. RELATE Task -> for_event -> event_001 +5. CREATE Task { title: "Send summary", assignee: p_001, blockers: [task_001] } +``` + +Each step is validated before execution. Rollback on constraint violation. + +## Integration Patterns + +### With Causal Inference + +Log ontology mutations as causal actions: + +```python +# When creating/updating entities, also log to causal action log +action = { + "action": "create_entity", + "domain": "ontology", + "context": {"type": "Task", "project": "proj_001"}, + "outcome": "created" +} +``` + +### Cross-Skill Communication + +```python +# Email skill creates commitment +commitment = ontology.create("Commitment", { + "source_message": msg_id, + "description": "Send report by Friday", + "due": "2026-01-31" +}) + +# Task skill picks it up +tasks = ontology.query("Commitment", {"status": "pending"}) +for c in tasks: + ontology.create("Task", { + "title": c.description, + "due": c.due, + "source": c.id + }) +``` + +## Quick Start + +```bash +# Initialize ontology storage +mkdir -p memory/ontology +touch memory/ontology/graph.jsonl + +# Create schema (optional but recommended) +python3 scripts/ontology.py schema-append --data '{ + "types": { + "Task": { "required": ["title", "status"] }, + "Project": { "required": ["name"] }, + "Person": { "required": ["name"] } + } +}' + +# Start using +python3 scripts/ontology.py create --type Person --props '{"name":"Alice"}' +python3 scripts/ontology.py list --type Person +``` + +## References + +- `references/schema.md` — Full type definitions and constraint patterns +- `references/queries.md` — Query language and traversal examples + +## Instruction Scope + +Runtime instructions operate on local files (`memory/ontology/graph.jsonl` and `memory/ontology/schema.yaml`) and provide CLI usage for create/query/relate/validate; this is within scope. The skill reads/writes workspace files and will create the `memory/ontology` directory when used. Validation includes property/enum/forbidden checks, relation type/cardinality validation, acyclicity for relations marked `acyclic: true`, and Event `end >= start` checks; other higher-level constraints may still be documentation-only unless implemented in code. diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..fdafdf8 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn72dv4fm7ss7swbq47nnpad9x7zy2jh", + "slug": "ontology", + "version": "1.0.4", + "publishedAt": 1773249559725 +} \ No newline at end of file diff --git a/references/queries.md b/references/queries.md new file mode 100644 index 0000000..fefb678 --- /dev/null +++ b/references/queries.md @@ -0,0 +1,211 @@ +# Query Reference + +Query patterns and graph traversal examples. + +## Basic Queries + +### Get by ID + +```bash +python3 scripts/ontology.py get --id task_001 +``` + +### List by Type + +```bash +# All tasks +python3 scripts/ontology.py list --type Task + +# All people +python3 scripts/ontology.py list --type Person +``` + +### Filter by Properties + +```bash +# Open tasks +python3 scripts/ontology.py query --type Task --where '{"status":"open"}' + +# High priority tasks +python3 scripts/ontology.py query --type Task --where '{"priority":"high"}' + +# Tasks assigned to specific person (by property) +python3 scripts/ontology.py query --type Task --where '{"assignee":"p_001"}' +``` + +## Relation Queries + +### Get Related Entities + +```bash +# Tasks belonging to a project (outgoing) +python3 scripts/ontology.py related --id proj_001 --rel has_task + +# What projects does this task belong to (incoming) +python3 scripts/ontology.py related --id task_001 --rel part_of --dir incoming + +# All relations for an entity (both directions) +python3 scripts/ontology.py related --id p_001 --dir both +``` + +### Common Patterns + +```bash +# Who owns this project? +python3 scripts/ontology.py related --id proj_001 --rel has_owner + +# What events is this person attending? +python3 scripts/ontology.py related --id p_001 --rel attendee_of --dir outgoing + +# What's blocking this task? +python3 scripts/ontology.py related --id task_001 --rel blocked_by --dir incoming +``` + +## Programmatic Queries + +### Python API + +```python +from scripts.ontology import load_graph, query_entities, get_related + +# Load the graph +entities, relations = load_graph("memory/ontology/graph.jsonl") + +# Query entities +open_tasks = query_entities("Task", {"status": "open"}, "memory/ontology/graph.jsonl") + +# Get related +project_tasks = get_related("proj_001", "has_task", "memory/ontology/graph.jsonl") +``` + +### Complex Queries + +```python +# Find all tasks blocked by incomplete dependencies +def find_blocked_tasks(graph_path): + entities, relations = load_graph(graph_path) + blocked = [] + + for entity in entities.values(): + if entity["type"] != "Task": + continue + if entity["properties"].get("status") == "blocked": + # Find what's blocking it + blockers = get_related(entity["id"], "blocked_by", graph_path, "incoming") + incomplete_blockers = [ + b for b in blockers + if b["entity"]["properties"].get("status") != "done" + ] + if incomplete_blockers: + blocked.append({ + "task": entity, + "blockers": incomplete_blockers + }) + + return blocked +``` + +### Path Queries + +```python +# Find path between two entities +def find_path(from_id, to_id, graph_path, max_depth=5): + entities, relations = load_graph(graph_path) + + visited = set() + queue = [(from_id, [])] + + while queue: + current, path = queue.pop(0) + + if current == to_id: + return path + + if current in visited or len(path) >= max_depth: + continue + + visited.add(current) + + for rel in relations: + if rel["from"] == current and rel["to"] not in visited: + queue.append((rel["to"], path + [rel])) + if rel["to"] == current and rel["from"] not in visited: + queue.append((rel["from"], path + [{**rel, "direction": "incoming"}])) + + return None # No path found +``` + +## Query Patterns by Use Case + +### Task Management + +```bash +# All my open tasks +python3 scripts/ontology.py query --type Task --where '{"status":"open","assignee":"p_me"}' + +# Overdue tasks (requires custom script for date comparison) +# See references/schema.md for date handling + +# Tasks with no blockers +python3 scripts/ontology.py query --type Task --where '{"status":"open"}' +# Then filter in code for those with no incoming "blocks" relations +``` + +### Project Overview + +```bash +# All tasks in project +python3 scripts/ontology.py related --id proj_001 --rel has_task + +# Project team members +python3 scripts/ontology.py related --id proj_001 --rel has_member + +# Project goals +python3 scripts/ontology.py related --id proj_001 --rel has_goal +``` + +### People & Contacts + +```bash +# All people +python3 scripts/ontology.py list --type Person + +# People in an organization +python3 scripts/ontology.py related --id org_001 --rel has_member + +# What's assigned to this person +python3 scripts/ontology.py related --id p_001 --rel assigned_to --dir incoming +``` + +### Events & Calendar + +```bash +# All events +python3 scripts/ontology.py list --type Event + +# Events at a location +python3 scripts/ontology.py related --id loc_001 --rel located_at --dir incoming + +# Event attendees +python3 scripts/ontology.py related --id event_001 --rel attendee_of --dir incoming +``` + +## Aggregations + +For complex aggregations, use Python: + +```python +from collections import Counter + +def task_status_summary(project_id, graph_path): + """Count tasks by status for a project.""" + tasks = get_related(project_id, "has_task", graph_path) + statuses = Counter(t["entity"]["properties"].get("status", "unknown") for t in tasks) + return dict(statuses) + +def workload_by_person(graph_path): + """Count open tasks per person.""" + open_tasks = query_entities("Task", {"status": "open"}, graph_path) + workload = Counter(t["properties"].get("assignee") for t in open_tasks) + return dict(workload) +``` diff --git a/references/schema.md b/references/schema.md new file mode 100644 index 0000000..094c894 --- /dev/null +++ b/references/schema.md @@ -0,0 +1,322 @@ +# Ontology Schema Reference + +Full type definitions and constraint patterns for the ontology graph. + +## Core Types + +### Agents & People + +```yaml +Person: + required: [name] + properties: + name: string + email: string? + phone: string? + organization: ref(Organization)? + notes: string? + tags: string[]? + +Organization: + required: [name] + properties: + name: string + type: enum(company, team, community, government, other)? + website: url? + members: ref(Person)[]? +``` + +### Work Management + +```yaml +Project: + required: [name] + properties: + name: string + description: string? + status: enum(planning, active, paused, completed, archived) + owner: ref(Person)? + team: ref(Person)[]? + goals: ref(Goal)[]? + start_date: date? + end_date: date? + tags: string[]? + +Task: + required: [title, status] + properties: + title: string + description: string? + status: enum(open, in_progress, blocked, done, cancelled) + priority: enum(low, medium, high, urgent)? + assignee: ref(Person)? + project: ref(Project)? + due: datetime? + estimate_hours: number? + blockers: ref(Task)[]? + tags: string[]? + +Goal: + required: [description] + properties: + description: string + target_date: date? + status: enum(active, achieved, abandoned)? + metrics: object[]? + key_results: string[]? +``` + +### Time & Location + +```yaml +Event: + required: [title, start] + properties: + title: string + description: string? + start: datetime + end: datetime? + location: ref(Location)? + attendees: ref(Person)[]? + recurrence: object? # iCal RRULE format + status: enum(confirmed, tentative, cancelled)? + reminders: object[]? + +Location: + required: [name] + properties: + name: string + address: string? + city: string? + country: string? + coordinates: object? # {lat, lng} + timezone: string? +``` + +### Information + +```yaml +Document: + required: [title] + properties: + title: string + path: string? # Local file path + url: url? # Remote URL + mime_type: string? + summary: string? + content_hash: string? + tags: string[]? + +Message: + required: [content, sender] + properties: + content: string + sender: ref(Person) + recipients: ref(Person)[] + thread: ref(Thread)? + timestamp: datetime + platform: string? # email, slack, whatsapp, etc. + external_id: string? + +Thread: + required: [subject] + properties: + subject: string + participants: ref(Person)[] + messages: ref(Message)[] + status: enum(active, archived)? + last_activity: datetime? + +Note: + required: [content] + properties: + content: string + title: string? + tags: string[]? + refs: ref(Entity)[]? # Links to any entity + created: datetime +``` + +### Resources + +```yaml +Account: + required: [service, username] + properties: + service: string # github, gmail, aws, etc. + username: string + url: url? + credential_ref: ref(Credential)? + +Device: + required: [name, type] + properties: + name: string + type: enum(computer, phone, tablet, server, iot, other) + os: string? + identifiers: object? # {mac, serial, etc.} + owner: ref(Person)? + +Credential: + required: [service, secret_ref] + forbidden_properties: [password, secret, token, key, api_key] + properties: + service: string + secret_ref: string # Reference to secret store (e.g., "keychain:github-token") + expires: datetime? + scope: string[]? +``` + +### Meta + +```yaml +Action: + required: [type, target, timestamp] + properties: + type: string # create, update, delete, send, etc. + target: ref(Entity) + timestamp: datetime + actor: ref(Person|Agent)? + outcome: enum(success, failure, pending)? + details: object? + +Policy: + required: [scope, rule] + properties: + scope: string # What this policy applies to + rule: string # The constraint in natural language or code + enforcement: enum(block, warn, log) + enabled: boolean +``` + +## Relation Types + +### Ownership & Assignment + +```yaml +owns: + from_types: [Person, Organization] + to_types: [Account, Device, Document, Project] + cardinality: one_to_many + +has_owner: + from_types: [Project, Task, Document] + to_types: [Person] + cardinality: many_to_one + +assigned_to: + from_types: [Task] + to_types: [Person] + cardinality: many_to_one +``` + +### Hierarchy & Containment + +```yaml +has_task: + from_types: [Project] + to_types: [Task] + cardinality: one_to_many + +has_goal: + from_types: [Project] + to_types: [Goal] + cardinality: one_to_many + +member_of: + from_types: [Person] + to_types: [Organization] + cardinality: many_to_many + +part_of: + from_types: [Task, Document, Event] + to_types: [Project] + cardinality: many_to_one +``` + +### Dependencies + +```yaml +blocks: + from_types: [Task] + to_types: [Task] + acyclic: true # Prevents circular dependencies + cardinality: many_to_many + +depends_on: + from_types: [Task, Project] + to_types: [Task, Project, Event] + acyclic: true + cardinality: many_to_many + +requires: + from_types: [Action] + to_types: [Credential, Policy] + cardinality: many_to_many +``` + +### References + +```yaml +mentions: + from_types: [Document, Message, Note] + to_types: [Person, Project, Task, Event] + cardinality: many_to_many + +references: + from_types: [Document, Note] + to_types: [Document, Note] + cardinality: many_to_many + +follows_up: + from_types: [Task, Event] + to_types: [Event, Message] + cardinality: many_to_one +``` + +### Events + +```yaml +attendee_of: + from_types: [Person] + to_types: [Event] + cardinality: many_to_many + properties: + status: enum(accepted, declined, tentative, pending) + +located_at: + from_types: [Event, Person, Device] + to_types: [Location] + cardinality: many_to_one +``` + +## Global Constraints + +```yaml +constraints: + # Credentials must never store secrets directly + - type: Credential + rule: "forbidden_properties: [password, secret, token]" + message: "Credentials must use secret_ref to reference external secret storage" + + # Tasks must have valid status transitions + - type: Task + rule: "status transitions: open -> in_progress -> (done|blocked) -> done" + enforcement: warn + + # Events must have end >= start + - type: Event + rule: "if end exists: end >= start" + message: "Event end time must be after start time" + + # No orphan tasks (should belong to a project or have explicit owner) + - type: Task + rule: "has_relation(part_of, Project) OR has_property(owner)" + enforcement: warn + message: "Task should belong to a project or have an explicit owner" + + # Circular dependency prevention + - relation: blocks + rule: "acyclic" + message: "Circular task dependencies are not allowed" +``` diff --git a/scripts/ontology.py b/scripts/ontology.py new file mode 100644 index 0000000..040b435 --- /dev/null +++ b/scripts/ontology.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +""" +Ontology graph operations: create, query, relate, validate. + +Usage: + python ontology.py create --type Person --props '{"name":"Alice"}' + python ontology.py get --id p_001 + python ontology.py query --type Task --where '{"status":"open"}' + python ontology.py relate --from proj_001 --rel has_task --to task_001 + python ontology.py related --id proj_001 --rel has_task + python ontology.py list --type Person + python ontology.py delete --id p_001 + python ontology.py validate +""" + +import argparse +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path + +DEFAULT_GRAPH_PATH = "memory/ontology/graph.jsonl" +DEFAULT_SCHEMA_PATH = "memory/ontology/schema.yaml" + + +def resolve_safe_path( + user_path: str, + *, + root: Path | None = None, + must_exist: bool = False, + label: str = "path", +) -> Path: + """Resolve user path within root and reject traversal outside it.""" + if not user_path or not user_path.strip(): + raise SystemExit(f"Invalid {label}: empty path") + + safe_root = (root or Path.cwd()).resolve() + candidate = Path(user_path).expanduser() + if not candidate.is_absolute(): + candidate = safe_root / candidate + + try: + resolved = candidate.resolve(strict=False) + except OSError as exc: + raise SystemExit(f"Invalid {label}: {exc}") from exc + + try: + resolved.relative_to(safe_root) + except ValueError: + raise SystemExit( + f"Invalid {label}: must stay within workspace root '{safe_root}'" + ) + + if must_exist and not resolved.exists(): + raise SystemExit(f"Invalid {label}: file not found '{resolved}'") + + return resolved + + +def generate_id(type_name: str) -> str: + """Generate a unique ID for an entity.""" + prefix = type_name.lower()[:4] + suffix = uuid.uuid4().hex[:8] + return f"{prefix}_{suffix}" + + +def load_graph(path: str) -> tuple[dict, list]: + """Load entities and relations from graph file.""" + entities = {} + relations = [] + + graph_path = Path(path) + if not graph_path.exists(): + return entities, relations + + with open(graph_path) as f: + for line in f: + line = line.strip() + if not line: + continue + record = json.loads(line) + op = record.get("op") + + if op == "create": + entity = record["entity"] + entities[entity["id"]] = entity + elif op == "update": + entity_id = record["id"] + if entity_id in entities: + entities[entity_id]["properties"].update(record.get("properties", {})) + entities[entity_id]["updated"] = record.get("timestamp") + elif op == "delete": + entity_id = record["id"] + entities.pop(entity_id, None) + elif op == "relate": + relations.append({ + "from": record["from"], + "rel": record["rel"], + "to": record["to"], + "properties": record.get("properties", {}) + }) + elif op == "unrelate": + relations = [r for r in relations + if not (r["from"] == record["from"] + and r["rel"] == record["rel"] + and r["to"] == record["to"])] + + return entities, relations + + +def append_op(path: str, record: dict): + """Append an operation to the graph file.""" + graph_path = Path(path) + graph_path.parent.mkdir(parents=True, exist_ok=True) + + with open(graph_path, "a") as f: + f.write(json.dumps(record) + "\n") + + +def create_entity(type_name: str, properties: dict, graph_path: str, entity_id: str = None) -> dict: + """Create a new entity.""" + entity_id = entity_id or generate_id(type_name) + timestamp = datetime.now(timezone.utc).isoformat() + + entity = { + "id": entity_id, + "type": type_name, + "properties": properties, + "created": timestamp, + "updated": timestamp + } + + record = {"op": "create", "entity": entity, "timestamp": timestamp} + append_op(graph_path, record) + + return entity + + +def get_entity(entity_id: str, graph_path: str) -> dict | None: + """Get entity by ID.""" + entities, _ = load_graph(graph_path) + return entities.get(entity_id) + + +def query_entities(type_name: str, where: dict, graph_path: str) -> list: + """Query entities by type and properties.""" + entities, _ = load_graph(graph_path) + results = [] + + for entity in entities.values(): + if type_name and entity["type"] != type_name: + continue + + match = True + for key, value in where.items(): + if entity["properties"].get(key) != value: + match = False + break + + if match: + results.append(entity) + + return results + + +def list_entities(type_name: str, graph_path: str) -> list: + """List all entities of a type.""" + entities, _ = load_graph(graph_path) + if type_name: + return [e for e in entities.values() if e["type"] == type_name] + return list(entities.values()) + + +def update_entity(entity_id: str, properties: dict, graph_path: str) -> dict | None: + """Update entity properties.""" + entities, _ = load_graph(graph_path) + if entity_id not in entities: + return None + + timestamp = datetime.now(timezone.utc).isoformat() + record = {"op": "update", "id": entity_id, "properties": properties, "timestamp": timestamp} + append_op(graph_path, record) + + entities[entity_id]["properties"].update(properties) + entities[entity_id]["updated"] = timestamp + return entities[entity_id] + + +def delete_entity(entity_id: str, graph_path: str) -> bool: + """Delete an entity.""" + entities, _ = load_graph(graph_path) + if entity_id not in entities: + return False + + timestamp = datetime.now(timezone.utc).isoformat() + record = {"op": "delete", "id": entity_id, "timestamp": timestamp} + append_op(graph_path, record) + return True + + +def create_relation(from_id: str, rel_type: str, to_id: str, properties: dict, graph_path: str): + """Create a relation between entities.""" + timestamp = datetime.now(timezone.utc).isoformat() + record = { + "op": "relate", + "from": from_id, + "rel": rel_type, + "to": to_id, + "properties": properties, + "timestamp": timestamp + } + append_op(graph_path, record) + return record + + +def get_related(entity_id: str, rel_type: str, graph_path: str, direction: str = "outgoing") -> list: + """Get related entities.""" + entities, relations = load_graph(graph_path) + results = [] + + for rel in relations: + if direction == "outgoing" and rel["from"] == entity_id: + if not rel_type or rel["rel"] == rel_type: + if rel["to"] in entities: + results.append({ + "relation": rel["rel"], + "entity": entities[rel["to"]] + }) + elif direction == "incoming" and rel["to"] == entity_id: + if not rel_type or rel["rel"] == rel_type: + if rel["from"] in entities: + results.append({ + "relation": rel["rel"], + "entity": entities[rel["from"]] + }) + elif direction == "both": + if rel["from"] == entity_id or rel["to"] == entity_id: + if not rel_type or rel["rel"] == rel_type: + other_id = rel["to"] if rel["from"] == entity_id else rel["from"] + if other_id in entities: + results.append({ + "relation": rel["rel"], + "direction": "outgoing" if rel["from"] == entity_id else "incoming", + "entity": entities[other_id] + }) + + return results + + +def validate_graph(graph_path: str, schema_path: str) -> list: + """Validate graph against schema constraints.""" + entities, relations = load_graph(graph_path) + errors = [] + + # Load schema if exists + schema = load_schema(schema_path) + + type_schemas = schema.get("types", {}) + relation_schemas = schema.get("relations", {}) + global_constraints = schema.get("constraints", []) + + for entity_id, entity in entities.items(): + type_name = entity["type"] + type_schema = type_schemas.get(type_name, {}) + + # Check required properties + required = type_schema.get("required", []) + for prop in required: + if prop not in entity["properties"]: + errors.append(f"{entity_id}: missing required property '{prop}'") + + # Check forbidden properties + forbidden = type_schema.get("forbidden_properties", []) + for prop in forbidden: + if prop in entity["properties"]: + errors.append(f"{entity_id}: contains forbidden property '{prop}'") + + # Check enum values + for prop, allowed in type_schema.items(): + if prop.endswith("_enum"): + field = prop.replace("_enum", "") + value = entity["properties"].get(field) + if value and value not in allowed: + errors.append(f"{entity_id}: '{field}' must be one of {allowed}, got '{value}'") + + # Relation constraints (type + cardinality + acyclicity) + rel_index = {} + for rel in relations: + rel_index.setdefault(rel["rel"], []).append(rel) + + for rel_type, rel_schema in relation_schemas.items(): + rels = rel_index.get(rel_type, []) + from_types = rel_schema.get("from_types", []) + to_types = rel_schema.get("to_types", []) + cardinality = rel_schema.get("cardinality") + acyclic = rel_schema.get("acyclic", False) + + # Type checks + for rel in rels: + from_entity = entities.get(rel["from"]) + to_entity = entities.get(rel["to"]) + if not from_entity or not to_entity: + errors.append(f"{rel_type}: relation references missing entity ({rel['from']} -> {rel['to']})") + continue + if from_types and from_entity["type"] not in from_types: + errors.append( + f"{rel_type}: from entity {rel['from']} type {from_entity['type']} not in {from_types}" + ) + if to_types and to_entity["type"] not in to_types: + errors.append( + f"{rel_type}: to entity {rel['to']} type {to_entity['type']} not in {to_types}" + ) + + # Cardinality checks + if cardinality in ("one_to_one", "one_to_many", "many_to_one"): + from_counts = {} + to_counts = {} + for rel in rels: + from_counts[rel["from"]] = from_counts.get(rel["from"], 0) + 1 + to_counts[rel["to"]] = to_counts.get(rel["to"], 0) + 1 + + if cardinality in ("one_to_one", "many_to_one"): + for from_id, count in from_counts.items(): + if count > 1: + errors.append(f"{rel_type}: from entity {from_id} violates cardinality {cardinality}") + if cardinality in ("one_to_one", "one_to_many"): + for to_id, count in to_counts.items(): + if count > 1: + errors.append(f"{rel_type}: to entity {to_id} violates cardinality {cardinality}") + + # Acyclic checks + if acyclic: + graph = {} + for rel in rels: + graph.setdefault(rel["from"], []).append(rel["to"]) + + visited = {} + + def dfs(node, stack): + visited[node] = True + stack.add(node) + for nxt in graph.get(node, []): + if nxt in stack: + return True + if not visited.get(nxt, False): + if dfs(nxt, stack): + return True + stack.remove(node) + return False + + for node in graph: + if not visited.get(node, False): + if dfs(node, set()): + errors.append(f"{rel_type}: cyclic dependency detected") + break + + # Global constraints (limited enforcement) + for constraint in global_constraints: + ctype = constraint.get("type") + relation = constraint.get("relation") + rule = (constraint.get("rule") or "").strip().lower() + if ctype == "Event" and "end" in rule and "start" in rule: + for entity_id, entity in entities.items(): + if entity["type"] != "Event": + continue + start = entity["properties"].get("start") + end = entity["properties"].get("end") + if start and end: + try: + start_dt = datetime.fromisoformat(start) + end_dt = datetime.fromisoformat(end) + if end_dt < start_dt: + errors.append(f"{entity_id}: end must be >= start") + except ValueError: + errors.append(f"{entity_id}: invalid datetime format in start/end") + if relation and rule == "acyclic": + # Already enforced above via relations schema + continue + + return errors + + +def load_schema(schema_path: str) -> dict: + """Load schema from YAML if it exists.""" + schema = {} + schema_file = Path(schema_path) + if schema_file.exists(): + import yaml + with open(schema_file) as f: + schema = yaml.safe_load(f) or {} + return schema + + +def write_schema(schema_path: str, schema: dict) -> None: + """Write schema to YAML.""" + schema_file = Path(schema_path) + schema_file.parent.mkdir(parents=True, exist_ok=True) + import yaml + with open(schema_file, "w") as f: + yaml.safe_dump(schema, f, sort_keys=False) + + +def merge_schema(base: dict, incoming: dict) -> dict: + """Merge incoming schema into base, appending lists and deep-merging dicts.""" + for key, value in (incoming or {}).items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + base[key] = merge_schema(base[key], value) + elif key in base and isinstance(base[key], list) and isinstance(value, list): + base[key] = base[key] + [v for v in value if v not in base[key]] + else: + base[key] = value + return base + + +def append_schema(schema_path: str, incoming: dict) -> dict: + """Append/merge schema fragment into existing schema.""" + base = load_schema(schema_path) + merged = merge_schema(base, incoming) + write_schema(schema_path, merged) + return merged + + +def main(): + parser = argparse.ArgumentParser(description="Ontology graph operations") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Create + create_p = subparsers.add_parser("create", help="Create entity") + create_p.add_argument("--type", "-t", required=True, help="Entity type") + create_p.add_argument("--props", "-p", default="{}", help="Properties JSON") + create_p.add_argument("--id", help="Entity ID (auto-generated if not provided)") + create_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Get + get_p = subparsers.add_parser("get", help="Get entity by ID") + get_p.add_argument("--id", required=True, help="Entity ID") + get_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Query + query_p = subparsers.add_parser("query", help="Query entities") + query_p.add_argument("--type", "-t", help="Entity type") + query_p.add_argument("--where", "-w", default="{}", help="Filter JSON") + query_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # List + list_p = subparsers.add_parser("list", help="List entities") + list_p.add_argument("--type", "-t", help="Entity type") + list_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Update + update_p = subparsers.add_parser("update", help="Update entity") + update_p.add_argument("--id", required=True, help="Entity ID") + update_p.add_argument("--props", "-p", required=True, help="Properties JSON") + update_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Delete + delete_p = subparsers.add_parser("delete", help="Delete entity") + delete_p.add_argument("--id", required=True, help="Entity ID") + delete_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Relate + relate_p = subparsers.add_parser("relate", help="Create relation") + relate_p.add_argument("--from", dest="from_id", required=True, help="From entity ID") + relate_p.add_argument("--rel", "-r", required=True, help="Relation type") + relate_p.add_argument("--to", dest="to_id", required=True, help="To entity ID") + relate_p.add_argument("--props", "-p", default="{}", help="Relation properties JSON") + relate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Related + related_p = subparsers.add_parser("related", help="Get related entities") + related_p.add_argument("--id", required=True, help="Entity ID") + related_p.add_argument("--rel", "-r", help="Relation type filter") + related_p.add_argument("--dir", "-d", choices=["outgoing", "incoming", "both"], default="outgoing") + related_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + + # Validate + validate_p = subparsers.add_parser("validate", help="Validate graph") + validate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH) + validate_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH) + + # Schema append + schema_p = subparsers.add_parser("schema-append", help="Append/merge schema fragment") + schema_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH) + schema_p.add_argument("--data", "-d", help="Schema fragment as JSON") + schema_p.add_argument("--file", "-f", help="Schema fragment file (YAML or JSON)") + + args = parser.parse_args() + workspace_root = Path.cwd().resolve() + + if hasattr(args, "graph"): + args.graph = str( + resolve_safe_path(args.graph, root=workspace_root, label="graph path") + ) + if hasattr(args, "schema"): + args.schema = str( + resolve_safe_path(args.schema, root=workspace_root, label="schema path") + ) + if hasattr(args, "file") and args.file: + args.file = str( + resolve_safe_path( + args.file, root=workspace_root, must_exist=True, label="schema file" + ) + ) + + if args.command == "create": + props = json.loads(args.props) + entity = create_entity(args.type, props, args.graph, args.id) + print(json.dumps(entity, indent=2)) + + elif args.command == "get": + entity = get_entity(args.id, args.graph) + if entity: + print(json.dumps(entity, indent=2)) + else: + print(f"Entity not found: {args.id}") + + elif args.command == "query": + where = json.loads(args.where) + results = query_entities(args.type, where, args.graph) + print(json.dumps(results, indent=2)) + + elif args.command == "list": + results = list_entities(args.type, args.graph) + print(json.dumps(results, indent=2)) + + elif args.command == "update": + props = json.loads(args.props) + entity = update_entity(args.id, props, args.graph) + if entity: + print(json.dumps(entity, indent=2)) + else: + print(f"Entity not found: {args.id}") + + elif args.command == "delete": + if delete_entity(args.id, args.graph): + print(f"Deleted: {args.id}") + else: + print(f"Entity not found: {args.id}") + + elif args.command == "relate": + props = json.loads(args.props) + rel = create_relation(args.from_id, args.rel, args.to_id, props, args.graph) + print(json.dumps(rel, indent=2)) + + elif args.command == "related": + results = get_related(args.id, args.rel, args.graph, args.dir) + print(json.dumps(results, indent=2)) + + elif args.command == "validate": + errors = validate_graph(args.graph, args.schema) + if errors: + print("Validation errors:") + for err in errors: + print(f" - {err}") + else: + print("Graph is valid.") + + elif args.command == "schema-append": + if not args.data and not args.file: + raise SystemExit("schema-append requires --data or --file") + + incoming = {} + if args.data: + incoming = json.loads(args.data) + else: + path = Path(args.file) + if path.suffix.lower() == ".json": + with open(path) as f: + incoming = json.load(f) + else: + import yaml + with open(path) as f: + incoming = yaml.safe_load(f) or {} + + merged = append_schema(args.schema, incoming) + print(json.dumps(merged, indent=2)) + + +if __name__ == "__main__": + main()