mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: search normalizes the
knowledge kind in both directions, `Page.total` stays the server's own count,
and `put` picks its method from the name. Where it was not:
- kb.Import carries what the handler answers. `/v1/knowledge/import` writes
{format, imported, pages} (knowledge/import.go); this modelled `imported`
alone, so a caller could not see which pages landed.
- Fact carries the server's three. `GET /v1/graph` serves twelve members
(graph/typed.go, wireFact) and no other route hands back an assertion's
content address, so projecting the nine an asserter states made a read lossy
with nothing to recover it from. `id`, `by` and `knowable` arrive read-only
and `write()` never sends them.
- graph.extract and graph.ingest take a Source. Both routes bind the same
{source, subject, text, at} (graph/semantic.go, graphSourceIn), so it is one
value — and the first positional argument is now the same thing here as it is
in the other two.
- import_(format, data, project) — the order the contract writes and the other
two take. It was (export, *, format).
- A body carries what somebody set. `send` dropped None from the query and left
it in the body, so `search.find` posted {"index": null, "offset": null,
"project": null} where the other two omit the key: three requests for one
call, and cloud asked to read a value where there is none. Audit's pageSize
and p go the same way — the route's own defaults are the same 100 and 1, so
an unset term is one the route decides identically, and `all` pins the size it
walks at because it needs to know the page length.
- A write body is field data and nothing else. `url` belongs to a source, and an
empty title is left out so the doctype names the required field that is
missing rather than being handed a blank one.
- kb.list takes order and kb.links takes a project. `/v1/framework/{doctype}`
serves order_by and `/v1/knowledge/graph` serves ?project=.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so REFUSED is the two codes it does emit. A 403
read as denied on an invented code would hand an unauthenticated caller a cure
for a refusal nobody made.
- One refusal reader, by named fallback: code from `code` else `error`, reason
from `detail` else `message`, cures from `cure`. The gate's own `reason` —
"unpaid", "unresolved" — names the admit leg that failed rather than
explaining anything, so it is dropped, and `title` is not a refusal member.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- The fourth outcome is Fault, not the generated client's ApiException. It is
the one shared concept whose name a reader could not carry between languages,
and an exception from the generated layer is not this layer's vocabulary.
- A client with no credential builds. It raised at construction, which is a
process that cannot start where the other two are a process whose first call
fails — and the first call is where the credential is actually needed.
HIP-0044 is amended where the code was right and the document was not.
ruff check, ruff format --check and mypy clean on every changed file; 109
passed. The two test_smoke failures are pre-existing (ToolsApi is absent from
the generated tree) and reproduce on a stashed working copy.
Co-authored-by: Hanzo Dev <[email protected]>
373 lines
13 KiB
Python
373 lines
13 KiB
Python
"""Assertions with provenance and time.
|
|
|
|
Nothing overwrites anything. A retraction is an assertion, a superseded claim
|
|
and the one that superseded it both stay readable, and which of them wins is a
|
|
question you ask — :meth:`Graph.resolve` — rather than a state the store keeps.
|
|
|
|
`at` is when the thing was so; `seen` is when it became knowable, and defaults
|
|
to `at`. Both cross the wire as RFC 3339 and arrive here as `datetime`. Cloud
|
|
refuses an `at` more than five minutes ahead of its own clock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any, Dict, Tuple, Iterable, Optional, Sequence
|
|
from datetime import datetime
|
|
from dataclasses import dataclass
|
|
|
|
from hanzoai import (
|
|
wire, # qualified: this module has its own `text`, the document's
|
|
answer,
|
|
)
|
|
from hanzoai.wire import Reply
|
|
from hanzoai.answer import Answer
|
|
|
|
if TYPE_CHECKING: # the capability layer is written over the wire, not the generated client
|
|
from hanzoai.client import Client
|
|
|
|
__all__ = ["Fact", "Wrote", "Resolution", "Walk", "Triple", "Vocabulary", "Source", "Graph"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fact:
|
|
"""One assertion: `entity` `relation` `value`, and who said so, and when.
|
|
|
|
`names` declares that `value` is another entity's key, which makes the
|
|
assertion an edge — the only thing :meth:`Graph.walk` follows. `source` and
|
|
`evidence` are the provenance; `confidence` is how sure the source was.
|
|
|
|
`id`, `by` and `knowable` are the server's: it mints the content address,
|
|
stamps the filer from the validated principal, and derives when the row
|
|
became knowable. They arrive on a read and :meth:`write` never sends them,
|
|
so a fact read back and asserted again states the nine members an asserter
|
|
states and none of the three it cannot.
|
|
"""
|
|
|
|
entity: str = ""
|
|
relation: str = ""
|
|
value: str = ""
|
|
names: bool = False
|
|
at: Optional[datetime] = None
|
|
seen: Optional[datetime] = None
|
|
source: str = ""
|
|
evidence: str = ""
|
|
confidence: float = 0.0
|
|
#: The assertion's content address. Two callers who assert the identical
|
|
#: thing land on one id and one row. Read only.
|
|
id: str = ""
|
|
#: The identity that filed it — ``owner`` or ``owner/user``. Read only.
|
|
by: str = ""
|
|
#: The first instant this plane could have answered with the assertion,
|
|
#: which is what an as-of read is bounded by. Read only.
|
|
knowable: Optional[datetime] = None
|
|
|
|
def write(self) -> Dict[str, Any]:
|
|
"""This fact as an asserter states it.
|
|
|
|
An instant nobody set is left out rather than sent as ``""``: cloud
|
|
reads an absent `seen` as `at`, and an empty string as a timestamp that
|
|
is not RFC 3339. `id`, `by` and `knowable` are the server's and are
|
|
never sent.
|
|
"""
|
|
out: Dict[str, Any] = {
|
|
"entity": self.entity,
|
|
"relation": self.relation,
|
|
"value": self.value,
|
|
"names": self.names,
|
|
"source": self.source,
|
|
"evidence": self.evidence,
|
|
"confidence": self.confidence,
|
|
}
|
|
return {**out, **_when(at=self.at, seen=self.seen)}
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Fact":
|
|
return cls(
|
|
entity=wire.text(body, "entity"),
|
|
relation=wire.text(body, "relation"),
|
|
value=wire.text(body, "value"),
|
|
names=wire.flag(body, "names"),
|
|
at=wire.instant(body.get("at")) if isinstance(body, dict) else None,
|
|
seen=wire.instant(body.get("seen")) if isinstance(body, dict) else None,
|
|
source=wire.text(body, "source"),
|
|
evidence=wire.text(body, "evidence"),
|
|
confidence=wire.real(body, "confidence"),
|
|
id=wire.text(body, "id"),
|
|
by=wire.text(body, "by"),
|
|
knowable=wire.instant(body.get("knowable")) if isinstance(body, dict) else None,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Wrote:
|
|
"""What a batch did, member by member.
|
|
|
|
Each fact is judged on its own: one malformed assertion does not discard the
|
|
batch. `reasons` says why the refused ones were refused.
|
|
"""
|
|
|
|
recorded: int = 0
|
|
duplicate: int = 0
|
|
refused: int = 0
|
|
reasons: Tuple[str, ...] = ()
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Wrote":
|
|
return cls(
|
|
recorded=wire.number(body, "recorded"),
|
|
duplicate=wire.number(body, "duplicate"),
|
|
refused=wire.number(body, "refused"),
|
|
reasons=wire.strings(body, "reasons"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Resolution:
|
|
"""Which claim wins, and what argued against it.
|
|
|
|
`known` is `False` when nothing has been asserted — an answer, not an error.
|
|
`contested` is `True` only when a conflict claims a value different from the
|
|
winner's; two sources agreeing loudly are not a conflict.
|
|
"""
|
|
|
|
entity: str = ""
|
|
relation: str = ""
|
|
at: Optional[datetime] = None
|
|
known: bool = False
|
|
winner: Optional[Fact] = None
|
|
conflicts: Tuple[Fact, ...] = ()
|
|
contested: bool = False
|
|
truncated: bool = False
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Resolution":
|
|
winner = body.get("winner") if isinstance(body, dict) else None
|
|
return cls(
|
|
entity=wire.text(body, "entity"),
|
|
relation=wire.text(body, "relation"),
|
|
at=wire.instant(body.get("as_of")) if isinstance(body, dict) else None,
|
|
known=wire.flag(body, "known"),
|
|
winner=Fact.read(winner) if isinstance(winner, dict) else None,
|
|
conflicts=tuple(Fact.read(c) for c in wire.rows(body, "conflicts")),
|
|
contested=wire.flag(body, "contested"),
|
|
truncated=wire.flag(body, "truncated"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Walk:
|
|
"""The entities a walk reached. `bound` is the ceiling it was allowed."""
|
|
|
|
entities: Tuple[str, ...] = ()
|
|
depth: int = 0
|
|
bound: int = 0
|
|
truncated: bool = False
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Walk":
|
|
return cls(
|
|
entities=wire.strings(body, "entities"),
|
|
depth=wire.number(body, "depth"),
|
|
bound=wire.number(body, "bound"),
|
|
truncated=wire.flag(body, "truncated"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Triple:
|
|
"""What a document states, before anyone decides to record it.
|
|
|
|
`section` is where in the document it was found.
|
|
"""
|
|
|
|
subject: str = ""
|
|
predicate: str = ""
|
|
object: str = ""
|
|
names: bool = False
|
|
section: int = 0
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Triple":
|
|
return cls(
|
|
subject=wire.text(body, "subject"),
|
|
predicate=wire.text(body, "predicate"),
|
|
object=wire.text(body, "object"),
|
|
names=wire.flag(body, "names"),
|
|
section=wire.number(body, "section"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Source:
|
|
"""A document to read relations out of, and where it came from.
|
|
|
|
Both :meth:`Graph.extract` and :meth:`Graph.ingest` take it, because they
|
|
take the same thing and differ only in what they do with what they found.
|
|
"""
|
|
|
|
#: Where the text came from — a URL, a document id, a page title. Stamped
|
|
#: on every assertion as its source, and with the section number as its
|
|
#: evidence, so a claim can be traced back to the passage that made it.
|
|
source: str = ""
|
|
#: The document. A line written ``relation:: value`` states one relation;
|
|
#: prose states none.
|
|
text: str = ""
|
|
#: When what the source says was so. Part of every resulting assertion's
|
|
#: content address, so re-reading one source at one instant records one set
|
|
#: of rows however many times it is delivered.
|
|
at: Optional[datetime] = None
|
|
#: The entity the text is about before any heading names one.
|
|
subject: str = ""
|
|
|
|
def write(self) -> Dict[str, Any]:
|
|
"""This source as cloud reads it."""
|
|
return {"source": self.source, "text": self.text, "subject": self.subject, **_when(at=self.at)}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Vocabulary:
|
|
"""The relations in use, and the ordering that decides a conflict."""
|
|
|
|
relations: Tuple[str, ...] = ()
|
|
#: The terms of the precedence order, in the order they apply. The wire
|
|
#: member is ``rule``; this is plural because it is a list.
|
|
rules: Tuple[str, ...] = ()
|
|
bound: int = 0
|
|
|
|
@classmethod
|
|
def read(cls, body: Any) -> "Vocabulary":
|
|
return cls(
|
|
relations=wire.strings(body, "relations"),
|
|
rules=wire.strings(body, "rule"),
|
|
bound=wire.number(body, "bound"),
|
|
)
|
|
|
|
|
|
class Graph:
|
|
"""`c.graph`."""
|
|
|
|
def __init__(self, client: "Client") -> None:
|
|
self.client = client
|
|
|
|
def assert_(self, facts: Iterable[Fact]) -> Answer[Wrote]:
|
|
"""Record assertions. The trailing underscore is PEP 8's escape for a keyword.
|
|
|
|
Every member is judged on its own, so the three counts have to add up to
|
|
what was sent. A batch that does not is a transport fault — the request
|
|
and the answer are about different sets of facts — and raises rather
|
|
than pretending to be an :class:`hanzoai.Ok`.
|
|
"""
|
|
batch = tuple(facts)
|
|
reply: Reply = self.client.send("POST", "/v1/graph", body={"assertions": [f.write() for f in batch]})
|
|
a = answer.read(reply, Wrote.read)
|
|
if a.denied is None and a.held is None:
|
|
wrote = a.value
|
|
counted = wrote.recorded + wrote.duplicate + wrote.refused
|
|
if counted != len(batch):
|
|
raise answer.fault(
|
|
Reply(
|
|
status=reply.status,
|
|
body="{0} assertions sent, {1} accounted for".format(len(batch), counted),
|
|
request=reply.request,
|
|
)
|
|
)
|
|
return a
|
|
|
|
def read(
|
|
self,
|
|
*,
|
|
entity: Optional[str] = None,
|
|
relation: Optional[str] = None,
|
|
value: Optional[str] = None,
|
|
at: Optional[datetime] = None,
|
|
limit: Optional[int] = None,
|
|
) -> Tuple[Fact, ...]:
|
|
"""Assertions by key. Resolves nothing and withholds nothing.
|
|
|
|
A superseded claim and the one that superseded it both appear.
|
|
"""
|
|
body = self.client.read(
|
|
"GET",
|
|
"/v1/graph",
|
|
query={"entity": entity, "relation": relation, "value": value, "as_of": at, "limit": limit},
|
|
)
|
|
return tuple(Fact.read(f) for f in wire.rows(body, "assertions"))
|
|
|
|
def find(
|
|
self,
|
|
query: str,
|
|
*,
|
|
relation: Optional[str] = None,
|
|
at: Optional[datetime] = None,
|
|
limit: Optional[int] = None,
|
|
) -> Tuple[Fact, ...]:
|
|
"""Assertions by text. The same word as :meth:`hanzoai.search.Search.find`,
|
|
because it is the same act on a different corpus."""
|
|
body = self.client.read(
|
|
"GET",
|
|
"/v1/graph/search",
|
|
query={"q": query, "relation": relation, "as_of": at, "limit": limit},
|
|
)
|
|
return tuple(Fact.read(f) for f in wire.rows(body, "assertions"))
|
|
|
|
def resolve(self, entity: str, relation: str, at: Optional[datetime] = None) -> Resolution:
|
|
"""Which claim about `entity`'s `relation` wins, as of `at`. No `at` asks about now."""
|
|
return Resolution.read(
|
|
self.client.read(
|
|
"POST",
|
|
"/v1/graph/resolve",
|
|
body={"entity": entity, "relation": relation, **_when(as_of=at)},
|
|
)
|
|
)
|
|
|
|
def walk(
|
|
self,
|
|
seeds: Sequence[str],
|
|
*,
|
|
relation: Optional[str] = None,
|
|
direction: str = "out",
|
|
depth: Optional[int] = None,
|
|
at: Optional[datetime] = None,
|
|
) -> Walk:
|
|
"""The entities reachable from `seeds`. Only edges are followed.
|
|
|
|
`direction` is ``out``, ``in`` or ``both``.
|
|
"""
|
|
return Walk.read(
|
|
self.client.read(
|
|
"POST",
|
|
"/v1/graph/neighbors",
|
|
body={
|
|
"seeds": list(seeds),
|
|
"relation": relation,
|
|
"direction": direction,
|
|
"depth": depth,
|
|
**_when(as_of=at),
|
|
},
|
|
)
|
|
)
|
|
|
|
def extract(self, source: Source) -> Tuple[Triple, ...]:
|
|
"""Read what a document states, without recording any of it."""
|
|
body = self.client.read("POST", "/v1/graph/extract", body=source.write())
|
|
return tuple(Triple.read(t) for t in wire.rows(body, "triples"))
|
|
|
|
def ingest(self, source: Source) -> Answer[Wrote]:
|
|
"""Extract and record in one call, answering the same :class:`Wrote`."""
|
|
reply = self.client.send("POST", "/v1/graph/ingest", body=source.write())
|
|
return answer.read(reply, Wrote.read)
|
|
|
|
def vocabulary(self) -> Vocabulary:
|
|
"""The relations in use and the ordering that decides a conflict."""
|
|
return Vocabulary.read(self.client.read("GET", "/v1/graph/vocabulary"))
|
|
|
|
|
|
def _when(**instants: Optional[datetime]) -> Dict[str, str]:
|
|
"""The instants that were set, RFC 3339.
|
|
|
|
An instant nobody set is left off the body. Cloud reads an absent one as its
|
|
own default — now, or `at` for a `seen` — and an empty string as a timestamp
|
|
it refuses.
|
|
"""
|
|
return {name: wire.stamp(t) for name, t in instants.items() if t is not None}
|