build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 20:58:26 +02:00
parent c8ee7c8bdc
commit 70bc8c0388
8 changed files with 353 additions and 57 deletions

View File

@ -10,6 +10,7 @@ This repository contains a focused, production-minded foundation for the broader
- Deterministic replay function that regenerates NarrativeBlocks from the event log - Deterministic replay function that regenerates NarrativeBlocks from the event log
- A simple hash-chain ledger that anchors block versions - A simple hash-chain ledger that anchors block versions
- A FastAPI app to ingest events and query blocks - A FastAPI app to ingest events and query blocks
- Deterministic claim graph construction, redaction-aware Markdown rendering, and block-to-block audit diffs
- Tests and a build-ready pyproject/setup.cfg - Tests and a build-ready pyproject/setup.cfg
Getting started Getting started
@ -26,6 +27,14 @@ Run the API locally for development:
uvicorn idea70_narrativeweave_real_time.api:app --reload uvicorn idea70_narrativeweave_real_time.api:app --reload
Useful endpoints:
- `POST /ingest`
- `POST /build_block`
- `GET /block/{block_id}`
- `GET /block/{block_id}/render?redacted=true`
- `GET /block/{left_block_id}/diff/{right_block_id}`
Project layout Project layout
- idea70_narrativeweave_real_time/: python package - idea70_narrativeweave_real_time/: python package

View File

@ -1,2 +1,5 @@
"""idea70_narrativeweave_real_time package""" """idea70_narrativeweave_real_time package"""
from .models import Claim, ClaimGraph, EvidenceToken, NarrativeBlock, NarrativeDiff, Provenance, Signal, Source
__version__ = "0.1.0" __version__ = "0.1.0"

View File

@ -1,7 +1,6 @@
from typing import Dict, Any, List from typing import Any, Dict
from .models import NarrativeBlock, Source, Signal, Provenance
from datetime import datetime from .narrative import build_block_from_events
import uuid
def news_adapter_event(headline: str, uri: str, confidence: float = 0.9) -> Dict[str, Any]: def news_adapter_event(headline: str, uri: str, confidence: float = 0.9) -> Dict[str, Any]:
@ -20,44 +19,3 @@ def news_adapter_event(headline: str, uri: str, confidence: float = 0.9) -> Dict
def transcript_adapter_event(speaker: str, text: str, ts: str) -> Dict[str, Any]: def transcript_adapter_event(speaker: str, text: str, ts: str) -> Dict[str, Any]:
return {"type": "transcript", "speaker": speaker, "text": text, "ts": ts} return {"type": "transcript", "speaker": speaker, "text": text, "ts": ts}
def build_block_from_events(events: List[Dict[str, Any]]) -> NarrativeBlock:
"""Deterministically build a NarrativeBlock from a list of events.
This function demonstrates normalization logic: aggregating sources and signals.
"""
# derive an id deterministically from the concatenated event payloads
concat = "".join([str(e) for e in events])
block_id = uuid.uuid5(uuid.NAMESPACE_URL, concat).hex
topic = " | ".join(set([e.get("type") for e in events if e.get("type")]))
ts = datetime.utcnow()
sources = []
signals = []
sentiment_vals = []
for e in events:
if e.get("type") == "news":
sources.append(Source(type="news", uri=e.get("uri"), confidence=e.get("confidence")))
# naive deterministic sentiment from headline length
sentiment_vals.append(len(e.get("headline", "")) % 5 - 2)
if e.get("type") == "transcript":
sources.append(Source(type="transcript", uri=f"transcript://{e.get('speaker')}", confidence=1.0))
# a toy signal: word count
signals.append(Signal(name="word_count", value=float(len(e.get("text", "").split())), provenance={"speaker": e.get("speaker")}))
sentiment = None
if sentiment_vals:
sentiment = sum(sentiment_vals) / len(sentiment_vals)
prov = Provenance(trace_id=block_id)
block = NarrativeBlock(
id=block_id,
topic=topic,
timestamp=ts,
sources=sources,
signals=signals,
sentiment=sentiment,
provenance=prov,
)
return block

View File

@ -1,9 +1,13 @@
import json
from typing import Any, Dict
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Any, Dict
from .storage import EventLog from .storage import EventLog
from .adapters import build_block_from_events from .adapters import build_block_from_events
from .ledger import SimpleLedger from .ledger import SimpleLedger
from .narrative import diff_narrative_blocks, render_narrative_block
app = FastAPI(title="NarrativeWeave API") app = FastAPI(title="NarrativeWeave API")
@ -28,12 +32,8 @@ def build_block():
events = _store.read_events() events = _store.read_events()
if not events: if not events:
raise HTTPException(status_code=404, detail="no events") raise HTTPException(status_code=404, detail="no events")
# events are read as dicts with ts,type,payload block = build_block_from_events(events)
payloads = [e["payload"] for e in events]
block = build_block_from_events(payloads)
# produce deterministic JSON for ledger anchoring # produce deterministic JSON for ledger anchoring
import json
block_json = json.dumps(block.model_dump(), sort_keys=True, default=str) block_json = json.dumps(block.model_dump(), sort_keys=True, default=str)
anchor = _ledger.anchor(block_json) anchor = _ledger.anchor(block_json)
_store.save_block(block, ledger_anchor=anchor) _store.save_block(block, ledger_anchor=anchor)
@ -46,3 +46,21 @@ def get_block(block_id: str):
if not b: if not b:
raise HTTPException(status_code=404, detail="block not found") raise HTTPException(status_code=404, detail="block not found")
return b.dict() return b.dict()
@app.get("/block/{block_id}/render")
def render_block(block_id: str, redacted: bool = False):
b = _store.get_block(block_id)
if not b:
raise HTTPException(status_code=404, detail="block not found")
return {"block_id": block_id, "redacted": redacted, "markdown": render_narrative_block(b, redacted=redacted)}
@app.get("/block/{left_block_id}/diff/{right_block_id}")
def diff_blocks(left_block_id: str, right_block_id: str):
left = _store.get_block(left_block_id)
right = _store.get_block(right_block_id)
if not left or not right:
raise HTTPException(status_code=404, detail="block not found")
diff = diff_narrative_blocks(left, right)
return diff.dict()

View File

@ -1,5 +1,5 @@
from typing import List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
@ -22,6 +22,8 @@ class Provenance(BaseModel):
class NarrativeBlock(BaseModel): class NarrativeBlock(BaseModel):
model_config = ConfigDict()
id: str id: str
topic: str topic: str
timestamp: datetime timestamp: datetime
@ -32,6 +34,32 @@ class NarrativeBlock(BaseModel):
scenario_flags: List[str] = Field(default_factory=list) scenario_flags: List[str] = Field(default_factory=list)
provenance: Optional[Provenance] = None provenance: Optional[Provenance] = None
class Config: class EvidenceToken(BaseModel):
# make JSON serialization deterministic for replay kind: str
json_sort_keys = True uri: str
confidence: Optional[float] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class Claim(BaseModel):
id: str
statement: str
evidence: List[EvidenceToken] = Field(default_factory=list)
confidence: Optional[float] = None
signer: Optional[str] = None
signature: Optional[str] = None
class ClaimGraph(BaseModel):
block_id: str
claims: List[Claim] = Field(default_factory=list)
provenance: Optional[Provenance] = None
class NarrativeDiff(BaseModel):
left_block_id: str
right_block_id: str
changed_fields: List[Dict[str, Any]] = Field(default_factory=list)
added_claims: List[Claim] = Field(default_factory=list)
removed_claims: List[Claim] = Field(default_factory=list)
modified_claims: List[Dict[str, Any]] = Field(default_factory=list)

View File

@ -0,0 +1,231 @@
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Dict, List, Mapping, Optional, Sequence
from .models import Claim, ClaimGraph, EvidenceToken, NarrativeBlock, NarrativeDiff, Provenance, Signal, Source
def _model_dump(model: Any) -> Dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump()
return model.dict()
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump()
if hasattr(value, "dict"):
return value.dict()
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [_jsonable(item) for item in value]
if isinstance(value, tuple):
return [_jsonable(item) for item in value]
if isinstance(value, datetime):
return value.isoformat()
return value
def _utc_naive(value: Optional[Any]) -> datetime:
if value is None:
return datetime.fromtimestamp(0)
if isinstance(value, datetime):
ts = value
else:
text = str(value).replace("Z", "+00:00")
ts = datetime.fromisoformat(text)
if ts.tzinfo is not None:
ts = ts.astimezone(timezone.utc).replace(tzinfo=None)
return ts
def canonical_json(data: Any) -> str:
return json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
def _normalize_events(events: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for item in events:
if "payload" in item and "type" in item:
payload = dict(item["payload"])
event_type = str(item["type"])
ts = _utc_naive(item.get("ts") or payload.get("ts"))
else:
payload = dict(item)
event_type = str(payload.get("type", "unknown"))
ts = _utc_naive(payload.get("ts"))
normalized.append({"type": event_type, "payload": payload, "ts": ts})
normalized.sort(key=lambda entry: (entry["ts"], entry["type"], canonical_json(entry["payload"])))
return normalized
def build_claim_graph(block: NarrativeBlock) -> ClaimGraph:
claims: List[Claim] = []
sources = [_model_dump(source) for source in block.sources]
provenance = _model_dump(block.provenance) if block.provenance else None
if block.signals:
for signal in block.signals:
signal_data = _model_dump(signal)
evidence: List[EvidenceToken] = []
for source in block.sources:
source_data = _model_dump(source)
evidence.append(
EvidenceToken(
kind=source_data["type"],
uri=source_data["uri"],
confidence=source_data.get("confidence"),
metadata={},
)
)
if signal_data.get("provenance"):
evidence.append(
EvidenceToken(
kind="signal_provenance",
uri=f"signal://{signal_data['name']}",
confidence=1.0,
metadata=signal_data["provenance"],
)
)
claim_id = hashlib.sha256(canonical_json({"signal": signal_data, "sources": sources}).encode("utf-8")).hexdigest()
statement = f"{block.topic}: {signal_data['name']}={signal_data['value']}"
claims.append(
Claim(
id=claim_id,
statement=statement,
evidence=evidence,
confidence=signal_data.get("value"),
)
)
else:
claim_id = hashlib.sha256(canonical_json({"topic": block.topic, "sources": sources}).encode("utf-8")).hexdigest()
claims.append(
Claim(
id=claim_id,
statement=f"{block.topic}: no structured signals captured",
evidence=[
EvidenceToken(kind=source.type, uri=source.uri, confidence=source.confidence, metadata={})
for source in block.sources
],
confidence=block.sentiment,
)
)
return ClaimGraph(block_id=block.id, claims=claims, provenance=block.provenance)
def render_narrative_block(block: NarrativeBlock, redacted: bool = False) -> str:
graph = build_claim_graph(block)
lines: List[str] = []
lines.append(f"# {block.topic}")
lines.append(f"Block ID: `{block.id}`")
lines.append(f"Timestamp: `{block.timestamp.isoformat()}`")
if block.sentiment is not None:
lines.append(f"Sentiment: `{block.sentiment}`")
if block.risk_factors:
lines.append("Risk factors:")
for risk in sorted(block.risk_factors):
lines.append(f"- {risk}")
if block.scenario_flags:
lines.append("Scenario flags:")
for flag in sorted(block.scenario_flags):
lines.append(f"- {flag}")
lines.append("\n## Claims")
for claim in sorted(graph.claims, key=lambda item: item.id):
lines.append(f"- `{claim.id}` {claim.statement}")
for evidence in sorted(claim.evidence, key=lambda item: (item.kind, item.uri)):
uri = evidence.uri if not redacted else "[redacted]"
lines.append(f" - {evidence.kind}: {uri}")
if graph.provenance:
lines.append("\n## Provenance")
lines.append(f"- trace_id: `{graph.provenance.trace_id}`")
if graph.provenance.signer:
lines.append(f"- signer: `{graph.provenance.signer}`")
if graph.provenance.ledger_anchor:
lines.append(f"- ledger_anchor: `{graph.provenance.ledger_anchor}`")
return "\n".join(lines).strip() + "\n"
def diff_narrative_blocks(left: NarrativeBlock, right: NarrativeBlock) -> NarrativeDiff:
left_claims = {claim.id: claim for claim in build_claim_graph(left).claims}
right_claims = {claim.id: claim for claim in build_claim_graph(right).claims}
changed_fields: List[Dict[str, Any]] = []
for field_name in ["topic", "timestamp", "sentiment", "risk_factors", "scenario_flags", "sources", "signals", "provenance"]:
left_value = getattr(left, field_name)
right_value = getattr(right, field_name)
if left_value != right_value:
changed_fields.append(
{
"field": field_name,
"before": _jsonable(left_value),
"after": _jsonable(right_value),
}
)
added_claims = [right_claims[claim_id] for claim_id in sorted(set(right_claims) - set(left_claims))]
removed_claims = [left_claims[claim_id] for claim_id in sorted(set(left_claims) - set(right_claims))]
modified_claims: List[Dict[str, Any]] = []
for claim_id in sorted(set(left_claims) & set(right_claims)):
if _model_dump(left_claims[claim_id]) != _model_dump(right_claims[claim_id]):
modified_claims.append(
{
"claim_id": claim_id,
"before": _model_dump(left_claims[claim_id]),
"after": _model_dump(right_claims[claim_id]),
}
)
return NarrativeDiff(
left_block_id=left.id,
right_block_id=right.id,
changed_fields=changed_fields,
added_claims=added_claims,
removed_claims=removed_claims,
modified_claims=modified_claims,
)
def build_block_from_events(events: Sequence[Mapping[str, Any]]) -> NarrativeBlock:
normalized = _normalize_events(events)
concat = canonical_json(normalized)
block_id = hashlib.sha256(concat.encode("utf-8")).hexdigest()
topic = " | ".join(sorted({entry["type"] for entry in normalized if entry["type"]}))
timestamp = max((entry["ts"] for entry in normalized), default=datetime.fromtimestamp(0))
sources: List[Source] = []
signals: List[Signal] = []
sentiment_vals = []
for entry in normalized:
payload = entry["payload"]
if entry["type"] == "news":
sources.append(Source(type="news", uri=str(payload.get("uri", "")), confidence=float(payload.get("confidence", 0.0))))
sentiment_vals.append(len(payload.get("headline", "")) % 5 - 2)
elif entry["type"] == "transcript":
sources.append(Source(type="transcript", uri=f"transcript://{payload.get('speaker')}", confidence=1.0))
signals.append(
Signal(
name="word_count",
value=float(len(payload.get("text", "").split())),
provenance={"speaker": payload.get("speaker"), "ts": payload.get("ts")},
)
)
sentiment = sum(sentiment_vals) / len(sentiment_vals) if sentiment_vals else None
provenance = Provenance(trace_id=block_id)
return NarrativeBlock(
id=block_id,
topic=topic,
timestamp=timestamp,
sources=sources,
signals=signals,
sentiment=sentiment,
provenance=provenance,
)

42
tests/test_audit.py Normal file
View File

@ -0,0 +1,42 @@
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from idea70_narrativeweave_real_time.adapters import build_block_from_events, news_adapter_event, transcript_adapter_event
from idea70_narrativeweave_real_time.narrative import diff_narrative_blocks, render_narrative_block
def test_redacted_rendering_keeps_structure():
events = [
{"ts": "2024-01-01T00:00:00Z", "type": "news", "payload": news_adapter_event("Shares rise", "https://example.com/story", 0.7)},
{"ts": "2024-01-01T00:00:01Z", "type": "transcript", "payload": transcript_adapter_event("CFO", "Revenue improved year over year.", "2024-01-01T00:00:01Z")},
]
block = build_block_from_events(events)
clear = render_narrative_block(block)
redacted = render_narrative_block(block, redacted=True)
assert block.id in clear
assert "[redacted]" in redacted
assert "https://example.com/story" not in redacted
assert "## Claims" in redacted
def test_diff_is_deterministic_and_claim_aware():
left_events = [
{"ts": "2024-01-01T00:00:00Z", "type": "news", "payload": news_adapter_event("Shares rise", "https://example.com/story", 0.7)},
]
right_events = [
{"ts": "2024-01-01T00:00:00Z", "type": "news", "payload": news_adapter_event("Shares fall", "https://example.com/story", 0.7)},
{"ts": "2024-01-01T00:00:02Z", "type": "transcript", "payload": transcript_adapter_event("CFO", "Margins compressed.", "2024-01-01T00:00:02Z")},
]
left = build_block_from_events(left_events)
right = build_block_from_events(right_events)
diff = diff_narrative_blocks(left, right)
assert diff.left_block_id == left.id
assert diff.right_block_id == right.id
assert diff.changed_fields
assert diff.added_claims or diff.modified_claims

View File

@ -8,6 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
from idea70_narrativeweave_real_time.storage import EventLog from idea70_narrativeweave_real_time.storage import EventLog
from idea70_narrativeweave_real_time.adapters import news_adapter_event, transcript_adapter_event, build_block_from_events from idea70_narrativeweave_real_time.adapters import news_adapter_event, transcript_adapter_event, build_block_from_events
from idea70_narrativeweave_real_time.ledger import SimpleLedger from idea70_narrativeweave_real_time.ledger import SimpleLedger
from idea70_narrativeweave_real_time.narrative import render_narrative_block
def test_deterministic_replay_and_ledger(): def test_deterministic_replay_and_ledger():
@ -19,12 +20,14 @@ def test_deterministic_replay_and_ledger():
el.append_event("transcript", transcript_adapter_event("CEO", "We delivered strong growth this quarter.", "2023-01-01T00:00:00Z")) el.append_event("transcript", transcript_adapter_event("CEO", "We delivered strong growth this quarter.", "2023-01-01T00:00:00Z"))
events = el.read_events() events = el.read_events()
payloads = [e["payload"] for e in events] payloads = events
# build block twice and ensure identical id and deterministic JSON # build block twice and ensure identical id and deterministic JSON
b1 = build_block_from_events(payloads) b1 = build_block_from_events(payloads)
b2 = build_block_from_events(payloads) b2 = build_block_from_events(payloads)
assert b1.id == b2.id assert b1.id == b2.id
assert b1.timestamp == b2.timestamp
assert b1.topic == "news | transcript"
ledger = SimpleLedger() ledger = SimpleLedger()
import json import json
@ -34,3 +37,7 @@ def test_deterministic_replay_and_ledger():
# Anchors should be chained and deterministic # Anchors should be chained and deterministic
assert a1 != "" assert a1 != ""
assert a2 != a1 assert a2 != a1
rendered = render_narrative_block(b1)
assert "# news | transcript" in rendered
assert "word_count" in rendered