Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/docker-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,16 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Attest build provenance
# ``actions/attest-build-provenance`` requires Build & Validate
# Attestations to be enabled on the GitHub organisation (paid /
# Enterprise feature, or public repo). The image is already
# signed via buildkit's ``provenance: true`` flag above; this
# extra attestation is a belt-and-braces upload that is not
# mandatory. Make it advisory so a free-tier org doesn't fail
# the whole publish workflow.
- name: Attest build provenance (advisory)
uses: actions/attest-build-provenance@v2
continue-on-error: true
with:
subject-name: ${{ steps.ns.outputs.image }}
subject-digest: ${{ steps.build.outputs.digest }}
Expand Down
36 changes: 33 additions & 3 deletions docs/cicd.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,43 @@ Two opt-in features that are on by default:

- **Provenance**: `provenance: true` on `docker/build-push-action`
records an SLSA-style attestation that the image came from this
workflow run.
workflow run. This one is emitted by buildkit and travels with the
manifest — works on every org plan.
- **SBOM**: `sbom: true` attaches a CycloneDX SBOM to the manifest.
`cosign verify-attestation` can read it.

There is also an *advisory* `actions/attest-build-provenance` step
that uploads a separate signed attestation document to the GitHub
attestation store. That feature requires the *Build & Validate
Attestations* setting, which is gated behind a paid plan (or making
the repository public). The step is marked
`continue-on-error: true`, so a free-tier org simply logs the 403 and
the publish workflow stays green — the buildkit-emitted provenance
above is still applied.

The job's `permissions:` block grants the workflow `packages: write`
(GHCR push), `id-token: write` and `attestations: write` (SLSA
provenance attestation upload).
(GHCR push), `id-token: write` and `attestations: write` (the
attestation API call, when the feature is enabled).

### Package visibility

GHCR packages created by Actions are **private by default**. To pull
the image without a token, flip the visibility in the package
settings:

1. Go to <https://github.com/orgs/firefly-operationOS/packages/container/flydesk-idp/settings>.
2. Under *Danger Zone*, click **Change visibility** → **Public**.

Or pull with auth:

```bash
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <user> --password-stdin
docker pull ghcr.io/firefly-operationos/flydesk-idp:latest
```

The GitHub Action's built-in token has `packages: read` (and `write`
on the publish workflow) by default, so cross-repo CI pulls work
without an extra secret.

### Image labels

Expand Down
27 changes: 27 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,37 @@ addopts = "-v --tb=short -m 'not llm'"
[tool.ruff]
target-version = "py313"
line-length = 110
# CI clones the sibling firefly framework repos into ``./vendor/`` so
# the Dockerfile's BuildKit contexts resolve identically in local
# checkouts and in GitHub Actions. We don't lint third-party code on
# its way through our pipeline -- they have their own ruff config.
extend-exclude = ["vendor"]

[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]

[tool.ruff.lint.per-file-ignores]
# Public DTOs / interface enums map directly onto the JSON wire
# contract we ship to callers (``parentType``, ``fieldGroupName``,
# ``ruleId``, …). The mixedCase fields and Exception-suffix-less
# identifiers are intentional — renaming them would break the API.
"src/flydesk_idp/interfaces/**" = ["N801", "N811", "N815", "N818"]
# Domain exceptions on the jobs CQRS handlers + the webhook publisher
# follow the framework's exception-naming convention (no ``Error``
# suffix); renaming them would ripple through every controller advice
# mapping.
"src/flydesk_idp/core/services/jobs/**" = ["N818"]
"src/flydesk_idp/core/services/webhook/**" = ["N818"]
# Judge re-binds the public wire-contract field names locally; same
# justification as the interfaces package.
"src/flydesk_idp/core/services/judge/**" = ["N815"]
# field_validator imports stdlib ``UUID`` as ``PyUUID`` to disambiguate
# from the project's own UUID column type without renaming the column.
"src/flydesk_idp/core/services/validation/**" = ["N811"]
# Tests routinely build long inline DTOs; the line-length cap is a
# code-smell signal we don't enforce on test fixtures.
"tests/**" = ["E501"]

[tool.pyright]
pythonVersion = "3.13"
typeCheckingMode = "basic"
Expand Down
10 changes: 5 additions & 5 deletions src/flydesk_idp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
version="0.1.0",
description="Firefly Desk IDP -- multimodal document extraction with bounding boxes.",
scan_packages=[
"flydesk_idp.core", # @configuration class
"flydesk_idp.core.services.extract", # extract command handler
"flydesk_idp.core.services.jobs", # job command/query handlers
"flydesk_idp.web.controllers", # REST controllers
"flydesk_idp.web.advice", # exception advice
"flydesk_idp.core", # @configuration class
"flydesk_idp.core.services.extract", # extract command handler
"flydesk_idp.core.services.jobs", # job command/query handlers
"flydesk_idp.web.controllers", # REST controllers
"flydesk_idp.web.advice", # exception advice
],
)
class FlydeskIDPApplication:
Expand Down
20 changes: 5 additions & 15 deletions src/flydesk_idp/core/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ def webhook(self, settings: IDPSettings) -> WebhookPublisher:
# ------------------------------------------------------------------

@bean(name="database_health")
def database_health(
self, repository: ExtractionJobRepository
) -> SqlAlchemyHealthIndicator:
def database_health(self, repository: ExtractionJobRepository) -> SqlAlchemyHealthIndicator:
return SqlAlchemyHealthIndicator(repository.engine)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -135,20 +133,12 @@ def request_validator(self) -> RequestValidator:
return RequestValidator()

@bean
def visual_checker(
self, settings: IDPSettings, prompts: PromptCatalog
) -> VisualAuthenticityChecker:
return VisualAuthenticityChecker(
template=prompts.visual_authenticity, model=settings.model
)
def visual_checker(self, settings: IDPSettings, prompts: PromptCatalog) -> VisualAuthenticityChecker:
return VisualAuthenticityChecker(template=prompts.visual_authenticity, model=settings.model)

@bean
def content_checker(
self, settings: IDPSettings, prompts: PromptCatalog
) -> ContentAuthenticityChecker:
return ContentAuthenticityChecker(
template=prompts.content_authenticity, model=settings.model
)
def content_checker(self, settings: IDPSettings, prompts: PromptCatalog) -> ContentAuthenticityChecker:
return ContentAuthenticityChecker(template=prompts.content_authenticity, model=settings.model)

@bean
def judge(self, settings: IDPSettings, prompts: PromptCatalog) -> Judge:
Expand Down
28 changes: 19 additions & 9 deletions src/flydesk_idp/core/observability/outbound_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@

import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any, Iterator
from typing import Any

logger = logging.getLogger("flydesk_idp.outbound")

Expand Down Expand Up @@ -46,7 +47,11 @@ def log_outbound(
extras = " ".join(f"{k}={_format(v)}" for k, v in fields.items() if v is not None)
logger.info(
"outbound_call target=%s op=%s status=%s latency_ms=%.0f %s",
target, op, status, latency_ms, extras,
target,
op,
status,
latency_ms,
extras,
)


Expand Down Expand Up @@ -109,7 +114,6 @@ async def timed_agent_run(agent: Any, content: Any, *, op: str, model: str) -> A
the log line and never alters control flow.
"""
from fireflyframework_agentic.agents.context import AgentContext

from pyfly.observability.correlation import get_correlation_id

target = model.split(":", 1)[0] if ":" in model else "llm"
Expand All @@ -122,16 +126,24 @@ async def timed_agent_run(agent: Any, content: Any, *, op: str, model: str) -> A
except Exception as exc:
latency_ms = (time.monotonic() - started) * 1000
log_outbound(
target, op=op, status="error", latency_ms=latency_ms,
model=model, error=type(exc).__name__,
target,
op=op,
status="error",
latency_ms=latency_ms,
model=model,
error=type(exc).__name__,
correlation_id=correlation_id,
)
raise

latency_ms = (time.monotonic() - started) * 1000
usage_fields = _extract_usage_fields(result, model)
log_outbound(
target, op=op, status="ok", latency_ms=latency_ms, model=model,
target,
op=op,
status="ok",
latency_ms=latency_ms,
model=model,
correlation_id=correlation_id,
**usage_fields,
)
Expand All @@ -154,9 +166,7 @@ def _extract_usage_fields(result: Any, model: str) -> dict[str, Any]:
return {}
input_tokens = getattr(usage, "input_tokens", 0) or 0
output_tokens = getattr(usage, "output_tokens", 0) or 0
total_tokens = (
getattr(usage, "total_tokens", 0) or (input_tokens + output_tokens)
)
total_tokens = getattr(usage, "total_tokens", 0) or (input_tokens + output_tokens)
cache_write = getattr(usage, "cache_write_tokens", 0) or 0
cache_read = getattr(usage, "cache_read_tokens", 0) or 0
fields: dict[str, Any] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ async def check(
prompt.user,
BinaryContent(data=document_bytes, media_type=media_type),
]
run_result = await timed_agent_run(
agent, content, op="content_auth", model=model or self._model
)
run_result = await timed_agent_run(agent, content, op="content_auth", model=model or self._model)
raw = run_result.output
return ContentAuthenticity(
overall_integrity_status=raw.overall_integrity_status,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,16 @@ async def check(
prompt.user,
BinaryContent(data=document_bytes, media_type=media_type),
]
run_result = await timed_agent_run(
agent, content, op="visual_auth", model=model or self._model
)
run_result = await timed_agent_run(agent, content, op="visual_auth", model=model or self._model)
raw_by_name = {v.name: v for v in run_result.output.validations}
outcomes: list[VisualValidationOutcome] = []
for spec in doc.validators.visual:
raw = raw_by_name.get(spec.name)
if raw is None:
outcomes.append(
VisualValidationOutcome(name=spec.name, passed=False, confidence=0.0, notes="Not evaluated")
VisualValidationOutcome(
name=spec.name, passed=False, confidence=0.0, notes="Not evaluated"
)
)
continue
outcomes.append(
Expand Down
8 changes: 4 additions & 4 deletions src/flydesk_idp/core/services/bbox/bbox_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@
# Tunable thresholds. The defaults are conservative and chosen so the
# real-world notarial-deed run -- where every bbox covers ~3% of the
# page -- comes out as ``good``.
_AREA_MIN = 5e-5 # roughly 5px × 5px at 1000px width
_AREA_MIN = 5e-5 # roughly 5px × 5px at 1000px width
_AREA_TYP_MIN = 1e-4
_AREA_TYP_MAX = 0.5
_AREA_SUSPICIOUS = 0.7
_ASPECT_MIN = 1.0 / 30.0 # very narrow
_ASPECT_MAX = 30.0 # very wide
_EDGE_EPSILON = 1e-3 # tolerance for "exactly on the edge"
_ASPECT_MIN = 1.0 / 30.0 # very narrow
_ASPECT_MAX = 30.0 # very wide
_EDGE_EPSILON = 1e-3 # tolerance for "exactly on the edge"


class BboxValidator:
Expand Down
8 changes: 3 additions & 5 deletions src/flydesk_idp/core/services/classification/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ class _ClassifierOutput(BaseModel):
class ClassificationResult:
"""One classifier verdict for a single input file."""

document_type: str # canonical docType from candidates, or ``unmatched``
document_type: str # canonical docType from candidates, or ``unmatched``
confidence: float = 0.0
description: str = ""
notes: str = ""
matched: bool = True # False when document_type == ``unmatched``
matched: bool = True # False when document_type == ``unmatched``


class DocumentClassifier:
Expand Down Expand Up @@ -115,9 +115,7 @@ async def classify(
prompt.user,
BinaryContent(data=document_bytes, media_type=media_type),
]
run_result = await timed_agent_run(
agent, content, op="classifier", model=model or self._model
)
run_result = await timed_agent_run(agent, content, op="classifier", model=model or self._model)
raw: _ClassifierOutput = run_result.output

doc_type = (raw.document_type or "").strip() or UNMATCHED
Expand Down
21 changes: 10 additions & 11 deletions src/flydesk_idp/core/services/escalation/judge_escalator.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ def __init__(
self._default_threshold = default_threshold
self._default_model = default_model

async def maybe_escalate(
self, ctx: Any, request: ExtractionRequest
) -> EscalationInfo | None:
async def maybe_escalate(self, ctx: Any, request: ExtractionRequest) -> EscalationInfo | None:
"""Return an :class:`EscalationInfo` when escalation fires, ``None`` otherwise.

Mutates ``ctx.metadata["per_doc_extracted"]`` in place when the
Expand All @@ -73,9 +71,7 @@ async def maybe_escalate(
if escalation_model == primary_model:
return None # nothing to gain from same-model re-run

per_doc_extracted: dict[str, list[ExtractedFieldGroup]] = ctx.metadata.get(
"per_doc_extracted", {}
)
per_doc_extracted: dict[str, list[ExtractedFieldGroup]] = ctx.metadata.get("per_doc_extracted", {})
per_doc_inputs = ctx.metadata.get("per_doc_inputs", {})
primary_fail, total = _count_failures(per_doc_extracted)
if total == 0:
Expand All @@ -86,7 +82,10 @@ async def maybe_escalate(

logger.info(
"judge_escalation triggered primary_model=%s primary_rate=%.2f threshold=%.2f -> escalate to %s",
primary_model, primary_rate, threshold, escalation_model,
primary_model,
primary_rate,
threshold,
escalation_model,
)

new_per_doc: dict[str, list[ExtractedFieldGroup]] = {}
Expand Down Expand Up @@ -152,7 +151,9 @@ async def _re_judge(doc_type: str) -> None:
ctx.metadata["per_doc_model_used"] = used
logger.info(
"judge_escalation primary_rate=%.2f escalation_rate=%.2f accepted=%s",
primary_rate, new_rate, info.accepted,
primary_rate,
new_rate,
info.accepted,
)
return info

Expand All @@ -166,9 +167,7 @@ def _resolve_model(self, request: ExtractionRequest) -> str | None:
return request.options.escalation_model or self._default_model


def _count_failures(
per_doc_extracted: dict[str, list[ExtractedFieldGroup]]
) -> tuple[int, int]:
def _count_failures(per_doc_extracted: dict[str, list[ExtractedFieldGroup]]) -> tuple[int, int]:
"""Count fields with a bad judge verdict.

A "bad" verdict is any of:
Expand Down
6 changes: 2 additions & 4 deletions src/flydesk_idp/core/services/extract/extract_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ async def do_handle(self, command: ExtractCommand) -> ExtractionResult:
self._orchestrator.execute(command.request),
timeout=self._settings.sync_timeout_s,
)
except asyncio.TimeoutError as exc:
except TimeoutError as exc:
logger.warning("Sync extraction timed out after %ds", self._settings.sync_timeout_s)
raise TimeoutError(
f"extraction did not finish within {self._settings.sync_timeout_s}s"
) from exc
raise TimeoutError(f"extraction did not finish within {self._settings.sync_timeout_s}s") from exc
12 changes: 5 additions & 7 deletions src/flydesk_idp/core/services/extraction/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,21 +84,19 @@ async def extract(
raise
logger.warning(
"Primary model %s failed (%s); retrying on fallback %s",
model_id, exc, self._fallback_model,
)
fallback_agent = self._build_agent(
self._fallback_model, output_model, instructions=prompt.system
model_id,
exc,
self._fallback_model,
)
fallback_agent = self._build_agent(self._fallback_model, output_model, instructions=prompt.system)
result = await timed_agent_run(
fallback_agent, content, op="extract.fallback", model=self._fallback_model
)
return normalise_doc(result.output, doc), self._fallback_model

# ------------------------------------------------------------------

def _build_agent(
self, model_id: str, output_model: type, *, instructions: str
) -> FireflyAgent[Any, Any]:
def _build_agent(self, model_id: str, output_model: type, *, instructions: str) -> FireflyAgent[Any, Any]:
return FireflyAgent(
name=self._agent_name,
model=model_id,
Expand Down
Loading
Loading