diff --git a/migrations/053_clan_event_store.sql b/migrations/053_clan_event_store.sql new file mode 100644 index 00000000..814359f1 --- /dev/null +++ b/migrations/053_clan_event_store.sql @@ -0,0 +1,97 @@ +-- 053_clan_event_store.sql +-- Minimal CLAN event store: consent_events (immutable), artifacts (versioned), +-- outbox (pending/done), state_transitions (immutable) + +begin; + +create schema if not exists clan; + +-- 1) Immutable ConsentEvents +create table if not exists clan.clan_consent_events ( + consent_event_id text primary key, + payload jsonb not null, + decision_type text not null, + target_artifact_ids text[] not null, + request_id text not null, + created_ts timestamptz not null default now(), + constraint chk_consent_decision_type check (decision_type in ('approve', 'reject', 'revoke')) +); + +create index if not exists idx_clan_consent_events_created_ts + on clan.clan_consent_events(created_ts desc); + +-- 2) Versioned Artifacts (CAS by version) +create table if not exists clan.clan_artifacts ( + artifact_id text primary key, + artifact_type text not null, + status text not null, + visibility_level text not null, + payload jsonb not null default '{}'::jsonb, + provenance jsonb not null default '[]'::jsonb, + version bigint not null default 1, + created_ts timestamptz not null default now(), + updated_ts timestamptz not null default now(), + constraint chk_artifact_visibility check (visibility_level in ('public', 'interclan', 'incircle', 'soulsafe', 'sacred')), + constraint chk_artifact_status check ( + status in ( + 'draft', + 'waiting_for_consent', + 'needs_confirmation', + 'approved_for_execution', + 'confirmed', + 'rejected', + 'revoked' + ) + ) +); + +create index if not exists idx_clan_artifacts_status + on clan.clan_artifacts(status); + +create index if not exists idx_clan_artifacts_updated_ts + on clan.clan_artifacts(updated_ts desc); + +-- 3) Outbox apply_consent (idempotent by outbox_id) +create table if not exists clan.clan_outbox ( + outbox_id text primary key, -- outbox_{consent_event_id} + event_type text not null, -- apply_consent + consent_event_id text not null references clan.clan_consent_events(consent_event_id) on delete restrict, + target_artifact_ids text[] not null, + request_id text not null, + status text not null default 'pending', -- pending|done + attempts int not null default 0, + last_error text, + created_ts timestamptz not null default now(), + updated_ts timestamptz not null default now(), + constraint chk_outbox_status check (status in ('pending', 'done')), + constraint chk_outbox_event_type check (event_type in ('apply_consent')) +); + +create index if not exists idx_clan_outbox_pending + on clan.clan_outbox(status, created_ts); + +-- 4) Immutable Transition Log +create table if not exists clan.clan_state_transitions ( + transition_id text primary key, + ts timestamptz not null default now(), + artifact_id text not null references clan.clan_artifacts(artifact_id) on delete restrict, + artifact_type text not null, + from_status text not null, + to_status text not null, + op text not null, + consent_event_id text not null references clan.clan_consent_events(consent_event_id) on delete restrict, + decision_type text not null, + request_id text not null, + visibility_level text not null, + versions jsonb not null default '{}'::jsonb, + constraint chk_transition_decision_type check (decision_type in ('approve', 'reject', 'revoke')), + constraint chk_transition_visibility check (visibility_level in ('public', 'interclan', 'incircle', 'soulsafe', 'sacred')) +); + +create index if not exists idx_clan_transitions_artifact + on clan.clan_state_transitions(artifact_id, ts desc); + +create index if not exists idx_clan_transitions_consent + on clan.clan_state_transitions(consent_event_id, ts desc); + +commit; diff --git a/services/clan-consent-adapter/Dockerfile b/services/clan-consent-adapter/Dockerfile new file mode 100644 index 00000000..1be73d74 --- /dev/null +++ b/services/clan-consent-adapter/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +COPY clan_consent_outbox_worker.py . +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8111"] diff --git a/services/clan-consent-adapter/app.py b/services/clan-consent-adapter/app.py new file mode 100644 index 00000000..971febf4 --- /dev/null +++ b/services/clan-consent-adapter/app.py @@ -0,0 +1,225 @@ +import json +import os +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import Depends, FastAPI, Header, HTTPException +from pydantic import BaseModel, Field + +DB_PATH = Path(os.getenv("CLAN_CONSENT_DB_PATH", "/data/clan_consent.sqlite")) +API_KEY = os.getenv("CLAN_ADAPTER_API_KEY", "").strip() +VISIBILITY = {"public", "interclan", "incircle", "soulsafe", "sacred"} + +app = FastAPI(title="CLAN Consent Adapter", version="1.0.0") + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _connect() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def _init_db() -> None: + conn = _connect() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS consent_events ( + id TEXT PRIMARY KEY, + circle TEXT, + subject TEXT, + method TEXT, + signers_json TEXT, + caveats TEXT, + visibility_level TEXT, + provenance_json TEXT, + created_at TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS testimony_drafts ( + id TEXT PRIMARY KEY, + title TEXT, + circle TEXT, + visibility_level TEXT, + content_json TEXT, + status TEXT, + provenance_json TEXT, + consent_event_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + +class ConsentCreate(BaseModel): + circle: str + subject: str = Field(description="decision/testimony/bridge/rights") + method: str = "live_presence" + signers: list[str] = Field(default_factory=list) + caveats: Optional[str] = None + visibility_level: str = "incircle" + provenance: Dict[str, Any] = Field(default_factory=dict) + + +class TestimonyDraftCreate(BaseModel): + title: str + circle: str + visibility_level: str = "incircle" + content: Dict[str, Any] = Field(default_factory=dict) + status: str = "draft" + provenance: Dict[str, Any] = Field(default_factory=dict) + consent_event_id: Optional[str] = None + + +def _auth(authorization: Optional[str] = Header(default=None)) -> None: + if not API_KEY: + return + expected = f"Bearer {API_KEY}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _check_visibility(level: str) -> str: + v = (level or "").strip().lower() + if v not in VISIBILITY: + raise HTTPException(status_code=400, detail=f"invalid visibility_level: {level}") + return v + + +@app.on_event("startup") +def startup() -> None: + _init_db() + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "clan-consent-adapter"} + + +@app.post("/consent/events") +def create_consent_event(body: ConsentCreate, _: None = Depends(_auth)) -> Dict[str, Any]: + cid = str(uuid.uuid4()) + visibility = _check_visibility(body.visibility_level) + now = _utc_now() + conn = _connect() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO consent_events(id, circle, subject, method, signers_json, caveats, visibility_level, provenance_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + cid, + body.circle, + body.subject, + body.method, + json.dumps(body.signers, ensure_ascii=False), + body.caveats or "", + visibility, + json.dumps(body.provenance, ensure_ascii=False), + now, + ), + ) + conn.commit() + finally: + conn.close() + return {"consent_event_id": cid, "status": "confirmed", "visibility_level": visibility} + + +@app.get("/consent/events/{event_id}") +def get_consent_event(event_id: str, _: None = Depends(_auth)) -> Dict[str, Any]: + conn = _connect() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM consent_events WHERE id = ?", (event_id,)) + row = cur.fetchone() + finally: + conn.close() + if not row: + raise HTTPException(status_code=404, detail="consent_event_not_found") + return { + "id": row["id"], + "circle": row["circle"], + "subject": row["subject"], + "method": row["method"], + "signers": json.loads(row["signers_json"] or "[]"), + "caveats": row["caveats"], + "visibility_level": row["visibility_level"], + "provenance": json.loads(row["provenance_json"] or "{}"), + "created_at": row["created_at"], + } + + +@app.post("/testimony/drafts") +def create_testimony_draft(body: TestimonyDraftCreate, _: None = Depends(_auth)) -> Dict[str, Any]: + tid = str(uuid.uuid4()) + visibility = _check_visibility(body.visibility_level) + status = body.status if body.status in {"draft", "needs_confirmation", "confirmed"} else "draft" + now = _utc_now() + conn = _connect() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO testimony_drafts(id, title, circle, visibility_level, content_json, status, provenance_json, consent_event_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + tid, + body.title, + body.circle, + visibility, + json.dumps(body.content, ensure_ascii=False), + status, + json.dumps(body.provenance, ensure_ascii=False), + body.consent_event_id, + now, + now, + ), + ) + conn.commit() + finally: + conn.close() + return {"testimony_id": tid, "status": status, "visibility_level": visibility} + + +@app.get("/testimony/drafts/{testimony_id}") +def get_testimony_draft(testimony_id: str, _: None = Depends(_auth)) -> Dict[str, Any]: + conn = _connect() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM testimony_drafts WHERE id = ?", (testimony_id,)) + row = cur.fetchone() + finally: + conn.close() + if not row: + raise HTTPException(status_code=404, detail="testimony_not_found") + return { + "id": row["id"], + "title": row["title"], + "circle": row["circle"], + "visibility_level": row["visibility_level"], + "content": json.loads(row["content_json"] or "{}"), + "status": row["status"], + "provenance": json.loads(row["provenance_json"] or "{}"), + "consent_event_id": row["consent_event_id"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } diff --git a/services/clan-consent-adapter/clan_consent_outbox_worker.py b/services/clan-consent-adapter/clan_consent_outbox_worker.py new file mode 100644 index 00000000..974ea617 --- /dev/null +++ b/services/clan-consent-adapter/clan_consent_outbox_worker.py @@ -0,0 +1,398 @@ +import hashlib +import json +import os +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +import psycopg +from psycopg.rows import dict_row + + +PG_DSN = os.getenv("CLAN_PG_DSN", "postgresql://daarion:DaarionDB2026!@dagi-postgres:5432/daarion_main") +BATCH_SIZE = int(os.getenv("CLAN_OUTBOX_BATCH_SIZE", "10")) +POLL_INTERVAL = float(os.getenv("CLAN_OUTBOX_POLL_INTERVAL_SEC", "1.0")) +MAX_ARTIFACT_CAS_RETRIES = int(os.getenv("CLAN_OUTBOX_MAX_CAS_RETRIES", "5")) +APPLIER_ACTOR_ID = os.getenv("CLAN_CONSENT_APPLIER_ACTOR_ID", "system:consent-applier") + +CONSENT_TRANSITION_MAP = { + "bridge_request_draft": { + "approve": ("approved_for_execution", "export_validated"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "allocation_proposal": { + "approve": ("approved_for_execution", "validated"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "access_grant_draft": { + "approve": ("approved_for_execution", "policy_checked"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "visibility_change_draft": { + "approve": ("approved_for_execution", "policy_checked"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "offline_merge_plan": { + "approve": ("approved_for_execution", "merged"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "core_change_draft": { + "approve": ("needs_confirmation", "policy_checked"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, + "testimony_draft": { + "approve": ("confirmed", "validated"), + "reject": ("rejected", "validated"), + "revoke": ("revoked", "corrected"), + }, +} + + +def _id(prefix: str, seed: str) -> str: + h = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:20] + return f"{prefix}_{h}" + + +def _now_ts() -> int: + return int(time.time()) + + +def _provenance_has_consent(provenance: Any, consent_event_id: str) -> bool: + if not isinstance(provenance, list): + return False + for tr in provenance: + ctx = (tr or {}).get("context") or {} + op = ((tr or {}).get("operation") or {}).get("op") + if ctx.get("consent_event_ref") == consent_event_id and op in { + "validated", + "export_validated", + "policy_checked", + "merged", + "corrected", + }: + return True + return False + + +def _build_trail( + consent_event: Dict[str, Any], + request_id: str, + consent_event_id: str, + decision_type: str, + op: str, + visibility_level: str, + versions: Dict[str, Any], +) -> Dict[str, Any]: + return { + "event_id": _id("prov", f"{consent_event_id}:{op}:{_now_ts()}"), + "ts": _now_ts(), + "actor": {"type": "system", "id": APPLIER_ACTOR_ID}, + "source": {"channel": "internal", "request_id": request_id}, + "context": { + "visibility_level": visibility_level, + "consent_status": "confirmed" if decision_type == "approve" else "none", + "consent_event_ref": consent_event_id, + }, + "operation": {"op": op}, + "versions": versions, + "links": {}, + } + + +@dataclass +class OutboxRow: + outbox_id: str + consent_event_id: str + target_artifact_ids: List[str] + request_id: str + + +def _fetch_pending_outbox(cur, limit: int) -> List[OutboxRow]: + cur.execute( + """ + select outbox_id, consent_event_id, target_artifact_ids, request_id + from clan.clan_outbox + where status='pending' + order by created_ts + limit %s + for update skip locked + """, + (limit,), + ) + rows = cur.fetchall() or [] + return [ + OutboxRow( + outbox_id=r["outbox_id"], + consent_event_id=r["consent_event_id"], + target_artifact_ids=list(r["target_artifact_ids"] or []), + request_id=r["request_id"], + ) + for r in rows + ] + + +def _set_outbox_error(cur, outbox_id: str, error: str) -> None: + cur.execute( + """ + update clan.clan_outbox + set attempts = attempts + 1, + last_error = %s, + updated_ts = now() + where outbox_id = %s + """, + (error[:800], outbox_id), + ) + + +def _mark_outbox_done(cur, outbox_id: str) -> None: + cur.execute( + """ + update clan.clan_outbox + set status='done', updated_ts=now() + where outbox_id=%s and status='pending' + """, + (outbox_id,), + ) + + +def _get_consent_event(cur, consent_event_id: str) -> Optional[Dict[str, Any]]: + cur.execute( + "select payload from clan.clan_consent_events where consent_event_id=%s", + (consent_event_id,), + ) + row = cur.fetchone() + return row["payload"] if row else None + + +def _get_artifact(cur, artifact_id: str) -> Optional[Dict[str, Any]]: + cur.execute( + """ + select artifact_id, artifact_type, status, visibility_level, payload, provenance, version + from clan.clan_artifacts + where artifact_id=%s + """, + (artifact_id,), + ) + return cur.fetchone() + + +def _cas_update_artifact( + cur, + artifact_id: str, + expected_version: int, + new_status: str, + new_payload: Dict[str, Any], + new_provenance: Any, +) -> bool: + cur.execute( + """ + update clan.clan_artifacts + set status=%s, + payload=%s::jsonb, + provenance=%s::jsonb, + version=version+1, + updated_ts=now() + where artifact_id=%s and version=%s + """, + ( + new_status, + json.dumps(new_payload, ensure_ascii=False), + json.dumps(new_provenance, ensure_ascii=False), + artifact_id, + expected_version, + ), + ) + return cur.rowcount == 1 + + +def _insert_transition( + cur, + *, + consent_event_id: str, + decision_type: str, + request_id: str, + artifact_id: str, + artifact_type: str, + visibility_level: str, + from_status: str, + to_status: str, + op: str, + versions: Dict[str, Any], +) -> None: + transition_id = _id("tr", f"{consent_event_id}:{artifact_id}:{decision_type}:{to_status}:{op}") + cur.execute( + """ + insert into clan.clan_state_transitions( + transition_id, artifact_id, artifact_type, from_status, to_status, op, + consent_event_id, decision_type, request_id, visibility_level, versions + ) values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb) + on conflict (transition_id) do nothing + """, + ( + transition_id, + artifact_id, + artifact_type, + from_status, + to_status, + op, + consent_event_id, + decision_type, + request_id, + visibility_level, + json.dumps(versions, ensure_ascii=False), + ), + ) + + +def _validate_consent_min(consent_event: Dict[str, Any]) -> Tuple[bool, str]: + try: + decision_type = consent_event["decision"]["type"] + if decision_type not in {"approve", "reject", "revoke"}: + return False, "invalid decision.type" + target_ids = (consent_event.get("target") or {}).get("artifact_ids") or [] + if not target_ids: + return False, "empty target.artifact_ids" + + expires_at = (consent_event.get("decision") or {}).get("expires_at") + if expires_at is not None and int(expires_at) < _now_ts(): + return False, "consent expired" + + if decision_type == "approve": + confirmations = consent_event.get("confirmations") or [] + quorum = consent_event.get("quorum") or {} + required = int(quorum.get("required", 1)) + present = int(quorum.get("present", 0)) + if len(confirmations) < required or present < required: + return False, "quorum not met" + return True, "" + except Exception as e: + return False, f"validation_error:{e}" + + +def _apply_outbox(cur, outbox: OutboxRow) -> None: + consent = _get_consent_event(cur, outbox.consent_event_id) + if not consent: + _set_outbox_error(cur, outbox.outbox_id, "STOP_CONSENT_EVENT_MISSING: consent not found") + return + + ok, err = _validate_consent_min(consent) + if not ok: + _set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: {err}") + return + + decision_type = (consent.get("decision") or {}).get("type") + versions = { + "constitution_version": str((consent.get("versions") or {}).get("constitution_version") or "unknown"), + "protocol_version": str((consent.get("versions") or {}).get("protocol_version") or "unknown"), + "router_guard_version": str((consent.get("versions") or {}).get("router_guard_version") or "unknown"), + } + + for artifact_id in outbox.target_artifact_ids: + artifact = _get_artifact(cur, artifact_id) + if not artifact: + _set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_MISSING: artifact {artifact_id} not found") + return + + if artifact["artifact_type"] not in CONSENT_TRANSITION_MAP: + _set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: no transition for {artifact['artifact_type']}") + return + + if decision_type not in CONSENT_TRANSITION_MAP[artifact["artifact_type"]]: + _set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: decision not mapped {decision_type}") + return + + from_status = artifact["status"] + if from_status == "rejected" and decision_type == "approve": + _set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: one-way violation {artifact_id}") + return + + provenance = artifact["provenance"] + if _provenance_has_consent(provenance, outbox.consent_event_id): + continue + + to_status, op = CONSENT_TRANSITION_MAP[artifact["artifact_type"]][decision_type] + trail = _build_trail( + consent_event=consent, + request_id=outbox.request_id, + consent_event_id=outbox.consent_event_id, + decision_type=decision_type, + op=op, + visibility_level=artifact["visibility_level"], + versions=versions, + ) + + payload = artifact["payload"] + expected_version = int(artifact["version"]) + new_provenance = (provenance if isinstance(provenance, list) else []) + [trail] + + success = False + for _ in range(MAX_ARTIFACT_CAS_RETRIES): + success = _cas_update_artifact( + cur, + artifact_id=artifact_id, + expected_version=expected_version, + new_status=to_status, + new_payload=payload, + new_provenance=new_provenance, + ) + if success: + break + + current = _get_artifact(cur, artifact_id) + if not current: + break + expected_version = int(current["version"]) + payload = current["payload"] + provenance = current["provenance"] + if _provenance_has_consent(provenance, outbox.consent_event_id): + success = True + break + new_provenance = (provenance if isinstance(provenance, list) else []) + [trail] + + if not success: + _set_outbox_error(cur, outbox.outbox_id, f"CAS conflict exceeded for {artifact_id}") + return + + _insert_transition( + cur, + consent_event_id=outbox.consent_event_id, + decision_type=decision_type, + request_id=outbox.request_id, + artifact_id=artifact_id, + artifact_type=artifact["artifact_type"], + visibility_level=artifact["visibility_level"], + from_status=from_status, + to_status=to_status, + op=op, + versions=versions, + ) + + _mark_outbox_done(cur, outbox.outbox_id) + + +def main() -> None: + while True: + try: + with psycopg.connect(PG_DSN, row_factory=dict_row) as conn: + conn.autocommit = False + with conn.cursor() as cur: + outboxes = _fetch_pending_outbox(cur, BATCH_SIZE) + if not outboxes: + conn.rollback() + time.sleep(POLL_INTERVAL) + continue + for ob in outboxes: + _apply_outbox(cur, ob) + conn.commit() + except Exception: + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/services/clan-consent-adapter/requirements.txt b/services/clan-consent-adapter/requirements.txt new file mode 100644 index 00000000..d1388185 --- /dev/null +++ b/services/clan-consent-adapter/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 +psycopg[binary]==3.2.3 diff --git a/services/clan-visibility-guard/Dockerfile b/services/clan-visibility-guard/Dockerfile new file mode 100644 index 00000000..837cc8d1 --- /dev/null +++ b/services/clan-visibility-guard/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8112"] diff --git a/services/clan-visibility-guard/app.py b/services/clan-visibility-guard/app.py new file mode 100644 index 00000000..bd2c5ab1 --- /dev/null +++ b/services/clan-visibility-guard/app.py @@ -0,0 +1,80 @@ +from typing import Any, Dict, List + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +LEVELS = ["public", "interclan", "incircle", "soulsafe", "sacred"] +LEVEL_RANK = {k: i for i, k in enumerate(LEVELS)} +SENSITIVE_KEYWORDS = [ + "child", "children", "minor", "health", "trauma", "violence", "abuse", + "ребен", "дитин", "здоров", "травм", "насил", +] + +app = FastAPI(title="CLAN Visibility Guard", version="1.0.0") + + +class VisibilityCheck(BaseModel): + current_level: str + requested_level: str + + +class ClassifyRequest(BaseModel): + text: str + + +class RedactRequest(BaseModel): + text: str + target_level: str + + +def _norm(level: str) -> str: + v = (level or "").strip().lower() + if v not in LEVEL_RANK: + raise HTTPException(status_code=400, detail=f"invalid_visibility_level:{level}") + return v + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "clan-visibility-guard"} + + +@app.post("/visibility/check_downgrade") +def check_downgrade(body: VisibilityCheck) -> Dict[str, Any]: + cur = _norm(body.current_level) + req = _norm(body.requested_level) + allowed = LEVEL_RANK[req] >= LEVEL_RANK[cur] + return { + "allowed": allowed, + "reason": "ok" if allowed else "downgrade_requires_consent", + "current_level": cur, + "requested_level": req, + } + + +@app.post("/visibility/classify") +def classify(body: ClassifyRequest) -> Dict[str, Any]: + t = (body.text or "").lower() + flags: List[str] = [k for k in SENSITIVE_KEYWORDS if k in t] + recommended = "soulsafe" if flags else "incircle" + if any(x in t for x in ["sacred", "сакрал", "духовн"]): + recommended = "sacred" + return { + "recommended_level": recommended, + "sensitivity_flags": flags, + } + + +@app.post("/visibility/redact_for_level") +def redact_for_level(body: RedactRequest) -> Dict[str, Any]: + target = _norm(body.target_level) + txt = body.text or "" + redacted = txt + if target in {"public", "interclan", "incircle"}: + for token in ["ребен", "дитин", "здоров", "травм", "насил", "health", "trauma", "violence", "abuse"]: + redacted = redacted.replace(token, "[sensitive]") + return { + "target_level": target, + "redacted_text": redacted, + "changed": redacted != txt, + } diff --git a/services/clan-visibility-guard/requirements.txt b/services/clan-visibility-guard/requirements.txt new file mode 100644 index 00000000..ada379a3 --- /dev/null +++ b/services/clan-visibility-guard/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 diff --git a/services/oneok-calc-adapter/Dockerfile b/services/oneok-calc-adapter/Dockerfile new file mode 100644 index 00000000..281ce710 --- /dev/null +++ b/services/oneok-calc-adapter/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . + +EXPOSE 8089 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8089"] diff --git a/services/oneok-calc-adapter/app.py b/services/oneok-calc-adapter/app.py new file mode 100644 index 00000000..a70fe237 --- /dev/null +++ b/services/oneok-calc-adapter/app.py @@ -0,0 +1,96 @@ +import os +from typing import Any, Dict, List, Optional + +from fastapi import Depends, FastAPI, Header, HTTPException + +API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip() +BASE_RATE_PER_M2 = float(os.getenv("ONEOK_BASE_RATE_PER_M2", "3200")) +INSTALL_RATE_PER_M2 = float(os.getenv("ONEOK_INSTALL_RATE_PER_M2", "900")) +CURRENCY = os.getenv("ONEOK_CURRENCY", "UAH") + +app = FastAPI(title="1OK Calc Adapter", version="1.0.0") + + +def _auth(authorization: Optional[str] = Header(default=None)) -> None: + if not API_KEY: + return + expected = f"Bearer {API_KEY}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _normalize_units(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + units = payload.get("window_units") + if isinstance(units, list) and units: + return [u for u in units if isinstance(u, dict)] + # Compatibility alias used by some LLM tool calls. + units = payload.get("windows") + if isinstance(units, list) and units: + return [u for u in units if isinstance(u, dict)] + # fallback to one unit in root payload + if "width_mm" in payload and "height_mm" in payload: + return [payload] + return [] + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "oneok-calc-adapter"} + + +@app.post("/calc/window_quote") +def calc_window_quote(input_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + units = _normalize_units(input_payload) + if not units: + raise HTTPException(status_code=400, detail="window_units/windows or width_mm/height_mm required") + + line_items: List[Dict[str, Any]] = [] + subtotal = 0.0 + + for idx, unit in enumerate(units, 1): + width_mm = float(unit.get("width_mm") or 0) + height_mm = float(unit.get("height_mm") or 0) + if width_mm <= 0 or height_mm <= 0: + continue + area_m2 = (width_mm * height_mm) / 1_000_000.0 + base = round(area_m2 * BASE_RATE_PER_M2, 2) + install = round(area_m2 * INSTALL_RATE_PER_M2, 2) + total = round(base + install, 2) + subtotal += total + + line_items.append( + { + "item": unit.get("label") or f"window_{idx}", + "width_mm": width_mm, + "height_mm": height_mm, + "area_m2": round(area_m2, 3), + "base_price": base, + "install_price": install, + "total": total, + } + ) + + if not line_items: + raise HTTPException(status_code=400, detail="No valid window units for calculation") + + assumptions = [ + f"Базова ставка: {BASE_RATE_PER_M2} {CURRENCY}/м2", + f"Монтаж: {INSTALL_RATE_PER_M2} {CURRENCY}/м2", + "ОЦІНКА: без виїзного підтвердженого заміру ціна попередня.", + ] + + lead_days = 10 + if str(input_payload.get("urgency", "")).lower() in {"urgent", "терміново"}: + lead_days = 7 + + return { + "currency": CURRENCY, + "line_items": line_items, + "totals": { + "subtotal": round(subtotal, 2), + "discount": 0.0, + "grand_total": round(subtotal, 2), + }, + "assumptions": assumptions, + "lead_time_if_known": f"{lead_days} днів", + } diff --git a/services/oneok-calc-adapter/requirements.txt b/services/oneok-calc-adapter/requirements.txt new file mode 100644 index 00000000..ad4ca543 --- /dev/null +++ b/services/oneok-calc-adapter/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 diff --git a/services/oneok-crm-adapter/Dockerfile b/services/oneok-crm-adapter/Dockerfile new file mode 100644 index 00000000..a02e70c7 --- /dev/null +++ b/services/oneok-crm-adapter/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8088 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8088"] diff --git a/services/oneok-crm-adapter/app.py b/services/oneok-crm-adapter/app.py new file mode 100644 index 00000000..a215718e --- /dev/null +++ b/services/oneok-crm-adapter/app.py @@ -0,0 +1,372 @@ +import json +import os +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import Depends, FastAPI, Header, HTTPException, Query + +DB_PATH = Path(os.getenv("ONEOK_CRM_DB_PATH", "/data/oneok_crm.sqlite")) +API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip() + +app = FastAPI(title="1OK CRM Adapter", version="1.0.0") + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _connect() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def _init_db() -> None: + conn = _connect() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, + full_name TEXT, + phone TEXT, + email TEXT, + preferred_contact TEXT, + notes TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS sites ( + id TEXT PRIMARY KEY, + client_id TEXT, + address_text TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS window_units ( + id TEXT PRIMARY KEY, + site_id TEXT, + label TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, + client_id TEXT, + site_id TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + site_id TEXT, + job_type TEXT, + status TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + +def _auth(authorization: Optional[str] = Header(default=None)) -> None: + if not API_KEY: + return + expected = f"Bearer {API_KEY}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _upsert_generic( + table: str, + payload: Dict[str, Any], + *, + id_field: str = "id", + indexed_fields: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + entity_id = str(payload.get(id_field) or uuid.uuid4()) + now = _utc_now() + merged = dict(payload) + merged[id_field] = entity_id + idx = indexed_fields or {} + + conn = _connect() + try: + cur = conn.cursor() + cur.execute(f"SELECT id FROM {table} WHERE id = ?", (entity_id,)) + exists = cur.fetchone() is not None + + if table == "clients": + cur.execute( + """ + INSERT INTO clients(id, full_name, phone, email, preferred_contact, notes, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + full_name=excluded.full_name, + phone=excluded.phone, + email=excluded.email, + preferred_contact=excluded.preferred_contact, + notes=excluded.notes, + payload_json=excluded.payload_json, + updated_at=excluded.updated_at + """, + ( + entity_id, + str(idx.get("full_name") or ""), + str(idx.get("phone") or ""), + str(idx.get("email") or ""), + str(idx.get("preferred_contact") or ""), + str(idx.get("notes") or ""), + json.dumps(merged, ensure_ascii=False), + now, + now, + ), + ) + elif table == "sites": + cur.execute( + """ + INSERT INTO sites(id, client_id, address_text, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + client_id=excluded.client_id, + address_text=excluded.address_text, + payload_json=excluded.payload_json, + updated_at=excluded.updated_at + """, + ( + entity_id, + str(idx.get("client_id") or ""), + str(idx.get("address_text") or ""), + json.dumps(merged, ensure_ascii=False), + now, + now, + ), + ) + elif table == "window_units": + cur.execute( + """ + INSERT INTO window_units(id, site_id, label, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + site_id=excluded.site_id, + label=excluded.label, + payload_json=excluded.payload_json, + updated_at=excluded.updated_at + """, + ( + entity_id, + str(idx.get("site_id") or ""), + str(idx.get("label") or ""), + json.dumps(merged, ensure_ascii=False), + now, + now, + ), + ) + elif table == "quotes": + cur.execute( + """ + INSERT INTO quotes(id, client_id, site_id, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + client_id=excluded.client_id, + site_id=excluded.site_id, + payload_json=excluded.payload_json, + updated_at=excluded.updated_at + """, + ( + entity_id, + str(idx.get("client_id") or ""), + str(idx.get("site_id") or ""), + json.dumps(merged, ensure_ascii=False), + now, + now, + ), + ) + elif table == "jobs": + cur.execute( + """ + INSERT INTO jobs(id, site_id, job_type, status, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + site_id=excluded.site_id, + job_type=excluded.job_type, + status=excluded.status, + payload_json=excluded.payload_json, + updated_at=excluded.updated_at + """, + ( + entity_id, + str(idx.get("site_id") or ""), + str(idx.get("job_type") or ""), + str(idx.get("status") or "new"), + json.dumps(merged, ensure_ascii=False), + now, + now, + ), + ) + conn.commit() + finally: + conn.close() + return {"id": entity_id, "created": not exists} + + +@app.on_event("startup") +def _startup() -> None: + _init_db() + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "oneok-crm-adapter"} + + +@app.get("/crm/search_client") +def search_client(query: str = Query(...), _: None = Depends(_auth)) -> Dict[str, Any]: + q = f"%{query.strip().lower()}%" + conn = _connect() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT id, full_name, phone, email, payload_json, updated_at + FROM clients + WHERE lower(full_name) LIKE ? OR lower(phone) LIKE ? OR lower(email) LIKE ? + ORDER BY updated_at DESC + LIMIT 20 + """, + (q, q, q), + ) + rows = cur.fetchall() + finally: + conn.close() + items = [] + for r in rows: + payload = json.loads(r["payload_json"]) + payload["id"] = r["id"] + items.append(payload) + return {"count": len(items), "items": items} + + +@app.post("/crm/upsert_client") +def upsert_client(client_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + result = _upsert_generic( + "clients", + client_payload, + indexed_fields={ + "full_name": client_payload.get("full_name") or client_payload.get("label"), + "phone": client_payload.get("phone"), + "email": client_payload.get("email"), + "preferred_contact": client_payload.get("preferred_contact"), + "notes": client_payload.get("notes"), + }, + ) + return {"client_id": result["id"], "created": result["created"]} + + +@app.post("/crm/upsert_site") +def upsert_site(site_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + result = _upsert_generic( + "sites", + site_payload, + indexed_fields={ + "client_id": site_payload.get("client_id"), + "address_text": site_payload.get("address_text"), + }, + ) + return {"site_id": result["id"], "created": result["created"]} + + +@app.post("/crm/upsert_window_unit") +def upsert_window_unit(window_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + result = _upsert_generic( + "window_units", + window_payload, + indexed_fields={ + "site_id": window_payload.get("site_id"), + "label": window_payload.get("label") or window_payload.get("room"), + }, + ) + return {"window_id": result["id"], "created": result["created"]} + + +@app.post("/crm/create_quote") +def create_quote(quote_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + result = _upsert_generic( + "quotes", + quote_payload, + indexed_fields={ + "client_id": quote_payload.get("client_id"), + "site_id": quote_payload.get("site_id"), + }, + ) + return {"quote_id": result["id"], "created": result["created"]} + + +@app.patch("/crm/update_quote") +def update_quote(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + quote_id = body.get("quote_id") + patch = body.get("patch") + if not quote_id or not isinstance(patch, dict): + raise HTTPException(status_code=400, detail="quote_id and patch required") + + conn = _connect() + try: + cur = conn.cursor() + cur.execute("SELECT payload_json FROM quotes WHERE id = ?", (quote_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="quote not found") + payload = json.loads(row["payload_json"]) + payload.update(patch) + finally: + conn.close() + + result = _upsert_generic( + "quotes", + payload, + indexed_fields={"client_id": payload.get("client_id"), "site_id": payload.get("site_id")}, + ) + return {"quote_id": result["id"], "updated": True} + + +@app.post("/crm/create_job") +def create_job(job_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + result = _upsert_generic( + "jobs", + job_payload, + indexed_fields={ + "site_id": job_payload.get("site_id"), + "job_type": job_payload.get("job_type"), + "status": job_payload.get("status") or "new", + }, + ) + return {"job_id": result["id"], "created": result["created"]} diff --git a/services/oneok-crm-adapter/requirements.txt b/services/oneok-crm-adapter/requirements.txt new file mode 100644 index 00000000..ad4ca543 --- /dev/null +++ b/services/oneok-crm-adapter/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 diff --git a/services/oneok-docs-adapter/Dockerfile b/services/oneok-docs-adapter/Dockerfile new file mode 100644 index 00000000..a995e33b --- /dev/null +++ b/services/oneok-docs-adapter/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8090 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/services/oneok-docs-adapter/app.py b/services/oneok-docs-adapter/app.py new file mode 100644 index 00000000..360fcf1e --- /dev/null +++ b/services/oneok-docs-adapter/app.py @@ -0,0 +1,102 @@ +import base64 +import html +import os +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import httpx +from fastapi import Depends, FastAPI, Header, HTTPException + +GOTENBERG_URL = os.getenv("ONEOK_GOTENBERG_URL", "http://oneok-gotenberg:3000").rstrip("/") +API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip() + +app = FastAPI(title="1OK Docs Adapter", version="1.0.0") + + +def _auth(authorization: Optional[str] = Header(default=None)) -> None: + if not API_KEY: + return + expected = f"Bearer {API_KEY}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _render_html(title: str, data: Dict[str, Any]) -> str: + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + rows = [] + for k, v in data.items(): + rows.append( + f"{html.escape(str(k))}" + f"{html.escape(str(v))}" + ) + table = "\n".join(rows) if rows else "No data" + return f""" + + + + + + +

{html.escape(title)}

+
Generated: {html.escape(ts)}
+ {table}
+ +""" + + +async def _render_pdf(file_name: str, html_text: str) -> Dict[str, Any]: + endpoints = [ + f"{GOTENBERG_URL}/forms/chromium/convert/html", + f"{GOTENBERG_URL}/forms/libreoffice/convert", + ] + async with httpx.AsyncClient(timeout=60.0) as client: + files = {file_name: (file_name, html_text.encode("utf-8"), "text/html")} + last_error = None + pdf = b"" + for url in endpoints: + resp = await client.post(url, files=files) + if resp.status_code < 400: + pdf = resp.content + break + last_error = f"{url} -> {resp.status_code} {resp.text[:200]}" + if not pdf: + raise HTTPException(status_code=502, detail=f"Gotenberg error: {last_error}") + return { + "file_name": file_name.replace(".html", ".pdf"), + "mime": "application/pdf", + "size_bytes": len(pdf), + "pdf_base64": base64.b64encode(pdf).decode("utf-8"), + } + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "oneok-docs-adapter"} + + +@app.post("/docs/render_quote_pdf") +async def render_quote_pdf(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + quote_id = body.get("quote_id") + payload = body.get("quote_payload") or {} + if not quote_id and not payload: + raise HTTPException(status_code=400, detail="quote_id or quote_payload required") + if quote_id and "quote_id" not in payload: + payload = dict(payload) + payload["quote_id"] = quote_id + html_doc = _render_html("Комерційна пропозиція (1OK)", payload) + return await _render_pdf("quote.html", html_doc) + + +@app.post("/docs/render_invoice_pdf") +async def render_invoice_pdf(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + payload = body.get("invoice_payload") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="invoice_payload required") + html_doc = _render_html("Рахунок (1OK)", payload) + return await _render_pdf("invoice.html", html_doc) diff --git a/services/oneok-docs-adapter/requirements.txt b/services/oneok-docs-adapter/requirements.txt new file mode 100644 index 00000000..4f4d9eb3 --- /dev/null +++ b/services/oneok-docs-adapter/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +httpx==0.28.1 diff --git a/services/oneok-schedule-adapter/Dockerfile b/services/oneok-schedule-adapter/Dockerfile new file mode 100644 index 00000000..7e880672 --- /dev/null +++ b/services/oneok-schedule-adapter/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . + +EXPOSE 8091 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8091"] diff --git a/services/oneok-schedule-adapter/app.py b/services/oneok-schedule-adapter/app.py new file mode 100644 index 00000000..5894dacc --- /dev/null +++ b/services/oneok-schedule-adapter/app.py @@ -0,0 +1,67 @@ +import os +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import Depends, FastAPI, Header, HTTPException + +API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip() +DEFAULT_TZ = os.getenv("ONEOK_SCHEDULE_TZ", "Europe/Kyiv") + +app = FastAPI(title="1OK Schedule Adapter", version="1.0.0") + + +def _auth(authorization: Optional[str] = Header(default=None)) -> None: + if not API_KEY: + return + expected = f"Bearer {API_KEY}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _next_work_slots(count: int = 3) -> List[Dict[str, Any]]: + now = datetime.utcnow() + slots: List[Dict[str, Any]] = [] + d = now + while len(slots) < count: + d += timedelta(days=1) + if d.weekday() >= 5: + continue + day = d.strftime("%Y-%m-%d") + for hour in (10, 13, 16): + slots.append( + { + "slot_id": str(uuid.uuid4()), + "start_local": f"{day}T{hour:02d}:00:00", + "end_local": f"{day}T{hour+1:02d}:00:00", + "timezone": DEFAULT_TZ, + } + ) + if len(slots) >= count: + break + return slots + + +@app.get("/health") +def health() -> Dict[str, Any]: + return {"status": "ok", "service": "oneok-schedule-adapter", "timezone": DEFAULT_TZ} + + +@app.post("/schedule/propose_slots") +def propose_slots(params: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + count = int(params.get("count") or 3) + count = 3 if count < 1 else min(count, 8) + return {"slots": _next_work_slots(count=count)} + + +@app.post("/schedule/confirm_slot") +def confirm_slot(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]: + job_id = body.get("job_id") + slot = body.get("slot") + if not job_id or slot is None: + raise HTTPException(status_code=400, detail="job_id and slot required") + return { + "job_id": job_id, + "confirmed_slot": slot, + "status": "confirmed", + } diff --git a/services/oneok-schedule-adapter/requirements.txt b/services/oneok-schedule-adapter/requirements.txt new file mode 100644 index 00000000..ad4ca543 --- /dev/null +++ b/services/oneok-schedule-adapter/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1