services: add clan consent/visibility and oneok adapter stack

This commit is contained in:
Apple
2026-02-19 00:14:12 -08:00
parent dfc0ef1ceb
commit c201d105f6
20 changed files with 1510 additions and 0 deletions

View File

@@ -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;

View File

@@ -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"]

View File

@@ -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"],
}

View File

@@ -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()

View File

@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.3
psycopg[binary]==3.2.3

View File

@@ -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"]

View File

@@ -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,
}

View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.3

View File

@@ -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"]

View File

@@ -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} днів",
}

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1

View File

@@ -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"]

View File

@@ -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"]}

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1

View File

@@ -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"]

View File

@@ -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"<tr><td style='padding:6px;border:1px solid #ddd;vertical-align:top'><b>{html.escape(str(k))}</b></td>"
f"<td style='padding:6px;border:1px solid #ddd'>{html.escape(str(v))}</td></tr>"
)
table = "\n".join(rows) if rows else "<tr><td style='padding:6px;border:1px solid #ddd'>No data</td></tr>"
return f"""<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8" />
<style>
body {{ font-family: Arial, sans-serif; color: #111; margin: 24px; }}
h1 {{ margin: 0 0 8px; font-size: 24px; }}
.meta {{ color: #666; margin: 0 0 18px; font-size: 12px; }}
table {{ border-collapse: collapse; width: 100%; }}
td {{ font-size: 13px; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<div class="meta">Generated: {html.escape(ts)}</div>
<table>{table}</table>
</body>
</html>"""
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)

View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
httpx==0.28.1

View File

@@ -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"]

View File

@@ -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",
}

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1