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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

# Soter

//WIP

Soter is a humanitarian aid distribution platform built on the Stellar ecosystem (Soroban). It combines on-chain escrow and auditable events with off-chain verification and field-ready client apps.

## Features
Expand Down
132 changes: 118 additions & 14 deletions app/ai-service/api/v1/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import logging
from typing import Any, Dict, Optional
from datetime import datetime
import uuid

from fastapi import APIRouter, BackgroundTasks, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field, validator

import tasks
from exceptions import LoadShedError
Expand All @@ -18,21 +20,71 @@
router = APIRouter(tags=["inference"])


class ContractMetadata(BaseModel):
"""Contract-aware metadata for verification results."""
campaign_id: str = Field(..., description="Campaign identifier (UUID)")
claim_id: str = Field(..., description="Claim identifier (UUID)")
package_id: str = Field(..., description="Package identifier for on-chain anchoring")
transaction_hash: Optional[str] = Field(None, description="On-chain transaction hash")
contract_address: Optional[str] = Field(None, description="Smart contract address")
network: Optional[str] = Field("testnet", description="Stellar network (testnet/mainnet)")
chain_id: Optional[str] = Field("testnet", description="Chain identifier")
version: Optional[str] = Field("1.0.0", description="Metadata version")
timestamp: Optional[datetime] = Field(default_factory=datetime.utcnow, description="Metadata timestamp")

@validator('campaign_id', 'claim_id')
def validate_uuid(cls, v):
"""Validate that the ID is a valid UUID."""
try:
uuid.UUID(v)
return v
except ValueError:
raise ValueError(f"Invalid UUID: {v}")

@validator('network')
def validate_network(cls, v):
"""Validate network is one of the allowed values."""
if v not in ['testnet', 'mainnet', 'public']:
raise ValueError(f"network must be one of: testnet, mainnet, public")
return v


class InferenceRequest(BaseModel):
"""Request model for AI inference endpoints."""

type: str = "inference"
data: Optional[Dict[str, Any]] = None
priority: Optional[str] = "normal"

# Contract-aware metadata fields
campaign_id: Optional[str] = None
claim_id: Optional[str] = None
package_id: Optional[str] = None
transaction_hash: Optional[str] = None
contract_address: Optional[str] = None
network: Optional[str] = "testnet"

class Config:
json_schema_extra = {
"example": {
"type": "verification",
"data": {"document_url": "https://example.com/doc.pdf"},
"campaign_id": "123e4567-e89b-12d3-a456-426614174000",
"claim_id": "123e4567-e89b-12d3-a456-426614174001",
"package_id": "pkg_abc123def",
"network": "testnet"
}
}


class TaskStatusResponse(BaseModel):
"""Response model for task status."""

task_id: str
status: str
result: Optional[Any] = None
error: Optional[str] = None
metadata: Optional[ContractMetadata] = None


@router.post("/ai/inference")
Expand All @@ -42,19 +94,60 @@ async def create_inference_task(
):
"""
Create a background task for heavy AI inference.

Offloads time-consuming AI tasks to background workers. Use the
Offloads time-consuming AI tasks to background workers. Use the
returned ``task_id`` to poll for results via ``GET /ai/status/{task_id}``.

Contract-aware metadata fields are used to anchor verification results
to on-chain events during Testnet demos.
"""
logger.info(f"Creating inference task of type: {request.type}")

# Validate metadata if provided
metadata_errors = []
if request.campaign_id:
try:
uuid.UUID(request.campaign_id)
except ValueError:
metadata_errors.append(f"Invalid campaign_id: {request.campaign_id}")

if request.claim_id:
try:
uuid.UUID(request.claim_id)
except ValueError:
metadata_errors.append(f"Invalid claim_id: {request.claim_id}")

if request.package_id and not request.package_id.startswith('pkg_'):
metadata_errors.append(f"Invalid package_id format: {request.package_id}")

if metadata_errors:
raise HTTPException(
status_code=400,
detail=f"Metadata validation failed: {'; '.join(metadata_errors)}"
)

try:
# Build task payload with metadata
payload = {
"data": request.data or {},
"priority": request.priority or "normal",
}

# Add metadata if provided
if request.campaign_id:
payload["metadata"] = {
"campaign_id": request.campaign_id,
"claim_id": request.claim_id,
"package_id": request.package_id or f"pkg_{request.claim_id[:8] if request.claim_id else 'unknown'}",
"transaction_hash": request.transaction_hash,
"contract_address": request.contract_address,
"network": request.network or "testnet",
"timestamp": datetime.utcnow().isoformat(),
}

task_id = tasks.create_task(
task_type=request.type,
payload={
"data": request.data or {},
"priority": request.priority or "normal",
},
payload=payload,
)

return {
Expand All @@ -63,6 +156,7 @@ async def create_inference_task(
"status": "pending",
"message": "Task queued for processing",
"status_url": f"/v1/ai/status/{task_id}",
"metadata": payload.get("metadata"),
}

except LoadShedError:
Expand All @@ -76,9 +170,12 @@ async def create_inference_task(
async def get_task_status(task_id: str):
"""
Get the current status of a background inference task.

Poll this endpoint after creating a task. Possible status values:
Poll this endpoint after creating a task. Possible status values:
``pending``, ``processing``, ``completed``, ``failed``.

When completed, the result will include contract-aware metadata
that can be anchored to on-chain events.
"""
return await _get_task_status(task_id)

Expand All @@ -87,8 +184,8 @@ async def get_task_status(task_id: str):
async def get_job_status(task_id: str):
"""
Get the current status of a queued AI job.

This is the canonical poll endpoint for backend clients. Possible
This is the canonical poll endpoint for backend clients. Possible
status values: ``pending``, ``processing``, ``retrying``, ``completed``,
``failed``, ``cancelled``.
"""
Expand All @@ -104,6 +201,13 @@ async def _get_task_status(task_id: str):

if status_info.get("status") == "not_found":
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

# Ensure metadata is included in response
if status_info.get("result") and isinstance(status_info["result"], dict):
# If result has metadata, ensure it's properly formatted
if "metadata" not in status_info["result"] and "campaign_id" in status_info.get("payload", {}):
# Reconstruct metadata from payload
status_info["result"]["metadata"] = status_info["payload"].get("metadata")

return status_info

Expand Down Expand Up @@ -138,4 +242,4 @@ async def cancel_task(task_id: str):

except Exception as e:
logger.error(f"Failed to cancel task: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to cancel task: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to cancel task: {str(e)}")
2 changes: 1 addition & 1 deletion app/backend/src/audit/webhook.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { HmacGuard } from './hmac.guard';
import {
AiVerificationPayloadDto,
VerificationStatus,
} from '../ai-verification.dto'; // Changed from 'src/ai-verification.dto' to relative import
} from '../ai-verification.dto';

describe('WebhookController', () => {
let controller: WebhookController;
Expand Down
127 changes: 127 additions & 0 deletions app/backend/src/verification/dto/verification-result.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
IsUUID,
IsNotEmpty,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';

/**
* Stable identifiers that link verification results to on-chain events
*/
export class ContractAwareMetadata {
@IsUUID()
@IsNotEmpty()
campaignId: string;

@IsUUID()
@IsNotEmpty()
claimId: string;

@IsString()
@IsNotEmpty()
packageId: string;

@IsString()
@IsOptional()
transactionHash?: string;

@IsString()
@IsOptional()
contractAddress?: string;

@IsString()
@IsOptional()
chainId?: string;

@IsString()
@IsOptional()
network?: string;

@IsString()
@IsOptional()
version?: string;

@IsOptional()
timestamp?: Date;
}

/**
* Enhanced verification result with contract-aware metadata
*/
export class VerificationResultDto {
@IsNumber()
@Min(0)
@Max(1)
score: number;

@IsNumber()
@Min(0)
@Max(1)
confidence: number;

@IsOptional()
details?: {
factors?: string[];
riskLevel?: 'low' | 'medium' | 'high';
recommendations?: string[];
rawResponse?: any;
};

@IsOptional()
@Type(() => Date)
processedAt?: Date;

@IsOptional()
metadata?: ContractAwareMetadata;

@IsOptional()
@IsArray()
@IsString({ each: true })
warnings?: string[];

@IsOptional()
@IsArray()
@IsString({ each: true })
validationErrors?: string[];
}

/**
* DTO for webhook payload with metadata validation
*/
export class VerificationWebhookPayload {
@IsUUID()
@IsNotEmpty()
claimId: string;

@IsUUID()
@IsNotEmpty()
campaignId: string;

@IsString()
@IsNotEmpty()
packageId: string;

@IsOptional()
@IsString()
transactionHash?: string;

@IsOptional()
@IsString()
contractAddress?: string;

@IsOptional()
@IsString()
network?: string;

result: VerificationResultDto;

@IsOptional()
@IsArray()
@IsString({ each: true })
warnings?: string[];
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ContractAwareMetadata } from '../dto/verification-result.dto';

export interface AnchorMetadata {
campaignRef?: string | null;
claimId?: string | null;
Expand All @@ -20,4 +22,7 @@ export interface VerificationResult {
recommendations?: string[];
};
processedAt: Date;
metadata?: ContractAwareMetadata;
warnings?: string[];
validationErrors?: string[];
}
Loading
Loading