init: documentation and prototypes

This commit is contained in:
2025-12-01 22:01:19 +01:00
parent e936fb41fa
commit 45d60fc1a9
51 changed files with 2476 additions and 1 deletions

View File

@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
# Using SQLite for easy local testing without Docker.
# For Production, switch to PostgreSQL as per ADR-007.
DATABASE_URL = "sqlite+aiosqlite:///./dvd_local.db"
# DATABASE_URL = "postgresql+asyncpg://user:password@localhost/dvd_db"
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1,108 @@
import asyncio
import uuid
import time
from concurrent.futures import ProcessPoolExecutor
from contextlib import asynccontextmanager
from typing import Optional
from datetime import datetime
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import engine, Base, get_db
from app.models import Document, InteractionThread, Message
from app.schemas import DocumentCreate, DocumentResponse, ThreadCreate, MessageListResponse, MessageResponse
# --- Hybrid Concurrency Config (ADR-007) ---
process_pool = ProcessPoolExecutor(max_workers=4)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize DB (Auto-create tables for demo)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
process_pool.shutdown()
await engine.dispose()
app = FastAPI(title="DirektVermittlungDe API", lifespan=lifespan)
# --- Dependencies ---
async def get_current_user():
return {"id": "citizen-123", "role": "citizen"}
# --- CPU Bound Logic (Isolated) ---
def cpu_bound_validation(payload_base64: str) -> bool:
'''Simulates heavy crypto validation away from the Event Loop'''
time.sleep(0.1) # Simulate CPU work
return True
# --- Routes ---
@app.post("/documents", response_model=DocumentResponse, status_code=201)
async def upload_document(
doc_in: DocumentCreate,
db: AsyncSession = Depends(get_db),
user = Depends(get_current_user)
):
# 1. Offload CPU heavy validation
loop = asyncio.get_running_loop()
is_valid = await loop.run_in_executor(
process_pool,
cpu_bound_validation,
doc_in.encryptedPayload
)
if not is_valid:
raise HTTPException(status_code=400, detail="Invalid payload")
# 2. Mock S3 Upload
s3_key = f"s3://bucket/{uuid.uuid4()}.enc"
# 3. Create Record
new_doc = Document(
authority_id=doc_in.metadata.authorityId,
reference_number=doc_in.metadata.referenceNumber,
doc_type=doc_in.metadata.docType.value,
storage_path=s3_key,
status="ROUTED"
)
db.add(new_doc)
await db.commit()
await db.refresh(new_doc)
return DocumentResponse(
id=new_doc.id,
status=new_doc.status,
assignedUnit="Tax-Team-A"
)
@app.get("/threads/{thread_id}/messages", response_model=MessageListResponse)
async def get_messages(
thread_id: str,
limit: int = 20,
before: Optional[datetime] = Query(None),
db: AsyncSession = Depends(get_db)
):
# Cursor Pagination Logic
query = select(Message).where(Message.thread_id == uuid.UUID(thread_id))
if before:
query = query.where(Message.timestamp < before)
query = query.order_by(Message.timestamp.desc()).limit(limit)
result = await db.execute(query)
messages = result.scalars().all()
next_cursor = messages[-1].timestamp if messages else None
return MessageListResponse(
data=[
MessageResponse(
id=m.id,
senderRole=m.sender_role,
content=m.content_blob,
timestamp=m.timestamp
) for m in messages
],
nextCursor=next_cursor
)

View File

@@ -0,0 +1,53 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class Document(Base):
'''
Represents the DocumentEnvelope.
Implements ADR-001: Separation of Metadata and Payload.
'''
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
authority_id: Mapped[str] = mapped_column(String(50), index=True)
reference_number: Mapped[str] = mapped_column(String(50))
doc_type: Mapped[str] = mapped_column(String(20))
storage_path: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(20), default="RECEIVED")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
retention_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
threads = relationship("InteractionThread", back_populates="document")
class InteractionThread(Base):
__tablename__ = "threads"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"))
type: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20), default="OPEN")
document = relationship("Document", back_populates="threads")
messages = relationship("Message", back_populates="thread")
class Message(Base):
'''
Optimized for Cursor-Based Pagination (NFR-1).
'''
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
thread_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("threads.id"))
sender_role: Mapped[str] = mapped_column(String(20))
content_blob: Mapped[str] = mapped_column(String)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
thread = relationship("InteractionThread", back_populates="messages")
__table_args__ = (
Index('idx_msgs_thread_time', 'thread_id', 'timestamp'),
)

View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
from enum import Enum
class DocType(str, Enum):
NOTICE = "NOTICE"
COURT_ORDER = "COURT_ORDER"
GENERAL_INQUIRY = "GENERAL_INQUIRY"
class ThreadType(str, Enum):
TEXT_CHAT = "TEXT_CHAT"
CALLBACK_REQUEST = "CALLBACK_REQUEST"
APPOINTMENT = "APPOINTMENT"
class MetadataInput(BaseModel):
authorityId: str = Field(..., example="Finanzamt-Muenchen-I")
referenceNumber: str = Field(..., example="123/456/789")
docType: DocType
issuedAt: datetime
class DocumentCreate(BaseModel):
metadata: MetadataInput
encryptedPayload: str
class ThreadCreate(BaseModel):
type: ThreadType
initialMessage: str
preferredTimeSlot: Optional[datetime] = None
class DocumentResponse(BaseModel):
id: UUID
status: str
assignedUnit: Optional[str] = None
class MessageResponse(BaseModel):
id: UUID
senderRole: str
content: str
timestamp: datetime
class MessageListResponse(BaseModel):
data: List[MessageResponse]
nextCursor: Optional[datetime] = None

View File

@@ -0,0 +1,7 @@
fastapi==0.109.0
uvicorn==0.27.0
sqlalchemy==2.0.25
asyncpg==0.29.0
pydantic==2.6.0
pydantic-settings==2.1.0
python-multipart==0.0.9